Browser freeze after upgrading to NextJS 16?!
Unanswered
Birman posted this in #help-forum
BirmanOP
Hi,
I have the weirdest bug ever after upgrading to Nextjs 16 (from 15) and adding cached components and suspense boundaries. I used the whole day yesterday to isolate why this is happening, but I can't really fix it.
Basically one of my routes freezes my Chrome tab completely. But it happens only on the preview/build, not in dev mode. If a different page is hydrated and I push to the route, it works fine. If I run preview and go to the url directly it also works, but once I refresh it freezes. So it might be related to cached components?
I'm using Nextjs 16.2.2 and HeroUI 2.7.5. I played some pingpong with Opus to remove and add different things from the route and these are some results:
Structure:
Tried:** replacing all HeroUI components with plain HTML, commenting out all hooks/effects/child components, stripping all imports — none helped. The file itself seems "cursed" at the bundler level.
So I can literally add the "anything here" div and build and when I refresh the page, my tab becomes unresponsive and crashes after a while, when I remove it, it works fine. I have no idea whats going on here, but it fails consistently.
I have the weirdest bug ever after upgrading to Nextjs 16 (from 15) and adding cached components and suspense boundaries. I used the whole day yesterday to isolate why this is happening, but I can't really fix it.
Basically one of my routes freezes my Chrome tab completely. But it happens only on the preview/build, not in dev mode. If a different page is hydrated and I push to the route, it works fine. If I run preview and go to the url directly it also works, but once I refresh it freezes. So it might be related to cached components?
I'm using Nextjs 16.2.2 and HeroUI 2.7.5. I played some pingpong with Opus to remove and add different things from the route and these are some results:
Structure:
// page.tsx
<Suspense fallback={<Loading />}>
<VideoContent slug={slug} /> ← async server component
</Suspense>
// VideoContent returns:
<>
<VideoPageWrapper video={...} siteConfig={...} /> ← "use client"
<div>anything here</div> ← triggers freeze
</>Tried:** replacing all HeroUI components with plain HTML, commenting out all hooks/effects/child components, stripping all imports — none helped. The file itself seems "cursed" at the bundler level.
So I can literally add the "anything here" div and build and when I refresh the page, my tab becomes unresponsive and crashes after a while, when I remove it, it works fine. I have no idea whats going on here, but it fails consistently.
12 Replies
BirmanOP
I tried deleting the whole .next folder, I tried incognito tab, nothing has helped. I also can't really debug anything because it straight up freezes everything.
BirmanOP
I also tried Safari and it reliably also crashes Safari tabs.
return (
#Unknown Channel
<JsonLd data={jsonLd} />
<ErrorBoundary>
<VideoPageWrapper
video={video}
siteConfig={siteConfig}
totalVideos={totalVideos}
/>
</ErrorBoundary>
<div className="mt-8 2xl:container 2xl:mx-auto">
<Suspense fallback={<RecommendedVideosSkeleton />}>
<RecommendedVideos />
</Suspense>
</div>
</>
);
The real code looks like this, If I remove the div at the bottom it works perfectly, if I remove the ErrorBoundary and keep the div it also works, if I add both it locks up.
#Unknown Channel
<JsonLd data={jsonLd} />
<ErrorBoundary>
<VideoPageWrapper
video={video}
siteConfig={siteConfig}
totalVideos={totalVideos}
/>
</ErrorBoundary>
<div className="mt-8 2xl:container 2xl:mx-auto">
<Suspense fallback={<RecommendedVideosSkeleton />}>
<RecommendedVideos />
</Suspense>
</div>
</>
);
The real code looks like this, If I remove the div at the bottom it works perfectly, if I remove the ErrorBoundary and keep the div it also works, if I add both it locks up.
Pacific anchoveta
try wrapping the whole thing in the error boundary
BirmanOP
I did fix this but its happening on other routes as well, it's completely crazy. It happens completely random. I can refresh a page 10 times and it loads in 1s and is super snappy and suddenly the next refresh throws the tab I assume in an infinite js loop. It's just stuck at 130% CPU, doesn't respond anymore. The network tab looks like this:
BirmanOP
I also tried to let Opus or Gemini find the issue serveral times but they seem to not have any clue what could cause an issue like this.
BirmanOP
Wtf, I found it. My json-ld tag caused it for whatever reason. I managed to pause the execution right when it froze in the Chrome sources tab and it stopped inside my json-ld script tag
I removed it and it stopped freezing
BirmanOP
I managed to get more details, but I can't open an issue because I can't get a minimal reproduction to freeze.
But I still have some details about whats happening, so I share them here, maybe it helps. The issue is way to deep for me, so I give the AI summary:
Bug: React's
Environment: Next.js 16.2.2, React 19.2.4,
What happens:
Rendering a
Root cause found via debugger:**
Pausing execution in Chrome DevTools Sources tab during the freeze consistently stops inside React's
But I still have some details about whats happening, so I share them here, maybe it helps. The issue is way to deep for me, so I give the AI summary:
Bug: React's
$RV reveal function enters infinite loop when <script> is rendered inside PPR-streamed Suspense boundaryEnvironment: Next.js 16.2.2, React 19.2.4,
cacheComponents: trueWhat happens:
Rendering a
<script type="application/ld+json"> with dangerouslySetInnerHTML (as recommended by [the Next.js JSON-LD docs](https://nextjs.org/docs/app/guides/json-ld)) inside a "use cache" async server component wrapped in <Suspense> intermittently causes the browser tab to enter an infinite loop at ~130% CPU on page refresh, eventually crashing the tab. It only occurs in production builds (next build && next start), never in next dev.Root cause found via debugger:**
Pausing execution in Chrome DevTools Sources tab during the freeze consistently stops inside React's
$RV function (the PPR/streaming Suspense reveal function) at this line:/dev/null/rv.js#L1-2
for (; e.firstChild; )
f.insertBefore(e.firstChild, c);The variables show
Call stack:
Page structure that triggers it:
Key conditions:
e and f are the same DOM element (e.g., both are div#S:2). This loop is supposed to move children from the replacement container (e) into the target parent (f). But when f === e, insertBefore moves children within the same node — e.firstChild is never null, creating an infinite loop.Call stack:
(anonymous) → $RC("B:4", "S:4") → requestAnimationFrame → $RVPage structure that triggers it:
/dev/null/page.tsx#L1-19
async function PageContent() {
"use cache";
cacheLife("video-content");
cacheTag("videos");
const data = await getData();
const jsonLd = buildSchema(data);
return (
<>
{/* This <script> causes the crash */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ClientComponent data={data} />
</>
);
}
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<PageContent />
</Suspense>
);
}Key conditions:
cacheComponents: true (PPR) + "use cache" + <Suspense> + <script> with dangerouslySetInnerHTML inside the cached component. The page had multiple nested Suspense boundaries (7+ template placeholders in the HTML), which may be relevant.Note:
A minimal reproduction was attempted but the bug could not be
triggered in isolation. The production app where this reliably
reproduces (within 3-10 Cmd+R refreshes) has these characteristics
that the minimal repro lacks:
- 18+ async JS chunks loaded via <script async> tags
- 7+ PPR streaming template boundaries (B:0 through B:6, P:4)
- 8 $RC calls batched into a single $RV call (Array(14))
- Root layout wraps {children} in <Suspense> (no fallback)
- Page wraps content in <Suspense fallback={<Loading />}>
- "use cache" component calls other "use cache" functions
(3 levels deep: PageContent → getVideos → getCurrentSiteId)
- ~90KB JSON-LD payload (60 VideoObject items with nested schema)
- 60 complex client components (VideoCard) rendering in the grid
- Multiple provider wrappers (ThemeProvider, ProgressProvider)
- next/font/google (Geist), @heroui/react component library
- reactCompiler: true, optimizePackageImports enabled
The bug is timing-dependent — likely related to how $RC calls
get batched and the order in which PPR streaming boundaries
are revealed.
$RV has no guard for the f === e case. Regardless of what causes the DOM structure corruption, adding if (f === e) continue; or similar to $RV would prevent the infinite loop from being possible.A minimal reproduction was attempted but the bug could not be
triggered in isolation. The production app where this reliably
reproduces (within 3-10 Cmd+R refreshes) has these characteristics
that the minimal repro lacks:
- 18+ async JS chunks loaded via <script async> tags
- 7+ PPR streaming template boundaries (B:0 through B:6, P:4)
- 8 $RC calls batched into a single $RV call (Array(14))
- Root layout wraps {children} in <Suspense> (no fallback)
- Page wraps content in <Suspense fallback={<Loading />}>
- "use cache" component calls other "use cache" functions
(3 levels deep: PageContent → getVideos → getCurrentSiteId)
- ~90KB JSON-LD payload (60 VideoObject items with nested schema)
- 60 complex client components (VideoCard) rendering in the grid
- Multiple provider wrappers (ThemeProvider, ProgressProvider)
- next/font/google (Geist), @heroui/react component library
- reactCompiler: true, optimizePackageImports enabled
The bug is timing-dependent — likely related to how $RC calls
get batched and the order in which PPR streaming boundaries
are revealed.
BirmanOP
I threw out the multi layer suspense and just put the json-ld at the top, but still the same result. Every time the page freezes and I pause the debugger it hangs somewhere inside the json-ld. When I remove it or inject it client side the page works perfectly fine.
The approach that breaks it is exactly the one from the docs: https://nextjs.org/docs/app/guides/json-ld
The approach that breaks it is exactly the one from the docs: https://nextjs.org/docs/app/guides/json-ld