Next.js Discord

Discord Forum

Browser freeze after upgrading to NextJS 16?!

Unanswered
Birman posted this in #help-forum
Open in Discord
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:
// 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.
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 $RV reveal function enters infinite loop when <script> is rendered inside PPR-streamed Suspense boundary

Environment: Next.js 16.2.2, React 19.2.4, cacheComponents: true

What 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 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$RV

Page 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: $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