React.lazy Infinitely Remounts in Turbopack Dev: It Is Not Your Zustand Store
Your app works in production. It works when you run next build && next start. It worked last month. Today, in next dev, one route mounts, flickers, and never becomes interactive. The component that holds most of your state renders, unmounts, renders again, forever, and the CPU fan spins up. You did not change that component. You start bisecting your own code, convinced you introduced an infinite render loop in your Zustand selectors.
You did not. If this route uses React.lazy() behind a Suspense boundary and the lazily-loaded subtree reads from a lot of external stores, and it only breaks in next dev with Turbopack, this is a known Turbopack development-mode bug, not a bug in your state management. Production builds and the Webpack dev server are unaffected. You can stop bisecting.
I want to be precise about this because the symptom points you at exactly the wrong layer. An infinite mount loop screams "you have a render loop", so you go read your reducers. The real cause is the dev bundler remounting a Suspense subtree it should have kept stable, and no amount of fixing your store will address it.
Turbopack's dev server can put a React.lazy() + Suspense subtree into an infinite unmount/remount loop when that subtree contains many useSyncExternalStore selectors (the Zustand pattern, 15 or more). Each remount is a brand-new component instance, so the page never stabilises. It does not reproduce with next dev --webpack or in production. This is tracked in the Next.js repo as an open Turbopack issue. Workarounds: run dev on Webpack, or eager-import the component in development and keep it lazy only in production.
The symptom, precisely
There is no clean error string thrown, which is part of why it is confusing. What you observe instead:
- The lazily-loaded component mounts, then immediately unmounts and mounts again, in a tight loop.
- Anything you use to prove instance identity confirms a new instance every cycle: a
useRefinitialised to a random value changes on every render pass, auseEffectcleanup runs every cycle, a mount counter never stops climbing. - The page never becomes interactive. Clicks do nothing because the tree tears down before it can settle.
- It only happens under
next devwith Turbopack. Switch to--webpackor run a production build and the same code is fine.
If you add a log to the component body you will see it fire endlessly:
function Dashboard() {
const id = useRef(Math.random()) // different every mount = new instance
useEffect(() => {
console.log("mounted", id.current)
return () => console.log("unmounted", id.current)
}, [])
// ... 15+ useSyncExternalStore-based selectors below
}
The mounted/unmounted pair repeating with a new id each time is the fingerprint. A genuine render loop inside your store would re-render the same instance; here the instance itself is being replaced.
Why this happens: the dev bundler, not your store
I will not pretend to have the internal fix; this is an open issue in Turbopack's development pipeline and the maintainers are working it. But the shape is clear enough to reason about, and clear enough to stop you blaming your own code.
React.lazy() returns a component whose real implementation is resolved by a dynamic import. Suspense renders a fallback until that import resolves, then renders the real subtree. For this to be stable, the resolved module has to keep a stable identity across renders: the same module, the same component reference, so React can reconcile it as the same element rather than a new one.
In Turbopack's dev mode, with hot-module machinery in play and a subtree that subscribes to many external stores through useSyncExternalStore, that stability breaks. Each time the external-store subscriptions fire during mount, something in the dev module resolution treats the lazy boundary as new, Suspense re-suspends, the subtree unmounts, the dynamic import resolves again to a fresh identity, and the cycle repeats. The many selectors matter because they generate the mount-time subscription activity that keeps re-triggering the boundary. This is why a component with one or two selectors usually survives and one with fifteen does not.
The two properties that make it a bundler bug and not an app bug: it does not reproduce under Webpack's dev server, and it does not reproduce in a production build. Same React, same stores, same component. The only variable is Turbopack's dev-mode module handling.
The fix, or rather the workarounds
There is no config key you set to make Turbopack stop doing this today. There are two reliable ways around it, and you pick by how much you rely on Turbopack's dev speed.
Workaround 1: Run the dev server on Webpack
In Next.js 16 Turbopack is the default bundler for next dev, so you opt out explicitly with the --webpack flag. There is no --no-turbopack flag and no environment variable that disables it; the supported way is --webpack.
{
"scripts": {
"dev": "next dev --webpack",
"build": "next build"
}
}
Note what this does not touch: your build still uses Turbopack (or whatever your build script says), so you keep Turbopack for production and only fall back to Webpack for the dev server where the bug lives. If you are on Next.js 15, where Turbopack dev was opt-in via --turbopack, the equivalent fix is simply to drop that flag so dev runs on Webpack.
The cost is Webpack dev-server speed, which is the whole reason Turbopack exists. On a large app that is a real daily tax, which is why some teams prefer the second option.
Workaround 2: Eager-import in development, lazy in production
The bug needs the React.lazy() boundary. If you skip the lazy boundary in development and only use it in production builds, Turbopack dev has nothing to remount, and production keeps the code-splitting you wanted lazy for in the first place.
import { lazy } from "react"
import DashboardEager from "./Dashboard"
// lazy() gives you code-splitting in prod; in dev it triggers the loop,
// so import the module directly there.
const Dashboard =
process.env.NODE_ENV === "development"
? DashboardEager
: lazy(() => import("./Dashboard"))
You still wrap it in Suspense so the production path is unchanged; the eager version simply resolves instantly and never suspends. This keeps Turbopack's dev speed everywhere except this one boundary, and keeps your production bundle split exactly as before. It is the better trade if only one or two components are affected.
Verification: confirm it is the bundler, in under a minute
Before you apply a workaround, prove the diagnosis so you are not treating a real render loop as a bundler bug:
- Run
next dev --webpack. If the loop stops, it is Turbopack dev, full stop. This one check is decisive. - Run
next build && next startand load the same route. If production is stable, it is not your store logic; a real infinite loop would fire in production too. - Temporarily replace the
React.lazy()import with a direct static import of the component. If the loop stops while still on Turbopack dev, you have confirmed the lazy boundary is the trigger.
If all three point the same way, the workaround is safe and you have not masked a genuine bug in your own code.
What people get wrong
Rewriting the Zustand stores. The instinct is to blame the fifteen selectors, so people merge stores, memoise selectors, add equality functions, wrap everything in useMemo. None of it helps, because the remount is above your state, at the lazy boundary. You will spend a day making your store code worse for no gain.
Reaching for key hacks or StrictMode toggles. Turning off StrictMode hides double-invocation in dev but does not stop this loop, and it also hides real bugs you want to see. Adding a stable key to the Suspense child does not fix a boundary that is being torn down by module resolution.
Assuming an upgrade already fixed it. This is an open issue at the time of writing, reported against recent Next.js 16.x and React 19.x. Do not assume a patch release silently resolved it; verify with the Webpack check above after upgrading before you remove the workaround.
When it is still broken
- Webpack dev also loops. Then it probably is a real render loop in your code after all. Look for a store update triggered unconditionally during render, or a selector that returns a new object every call so subscribers never see a stable value.
- Production also loops. Same conclusion: this is not the Turbopack bug. Profile the production build and find the state update firing on every render.
- The eager-import workaround changes behaviour. If a component genuinely depends on being lazily loaded (for example it must not run until visible), the eager path may run side effects earlier than expected. In that case prefer the
--webpackdev workaround instead. - You are chasing a different Turbopack dev quirk. Turbopack's dev module handling has other sharp edges; I have hit a "Cannot find module" with Pino and dynamic requires that is also dev-only. And if your real problem is dev memory rather than a remount loop, that is a separate issue with its own causes.
Track the upstream issue and remove your workaround once it is fixed and you have verified with the Webpack check. Until then, the honest engineering move is to route around a dev-tool bug rather than mangle production code to appease it. Your store was fine all along.
Frequently asked questions
- Why does my React.lazy component infinitely remount only in next dev?
- Because Turbopack's development server can put a React.lazy plus Suspense subtree into an unmount/remount loop when that subtree contains many useSyncExternalStore selectors, such as 15 or more Zustand selectors. Each cycle creates a new component instance so the page never becomes interactive. It does not reproduce under next dev --webpack or in a production build, which is how you know it is the dev bundler and not your state code.
- How do I disable Turbopack in Next.js 16 dev?
- Run next dev --webpack. In Next.js 16 Turbopack is the default bundler, and --webpack is the supported way to opt out; there is no --no-turbopack flag and no environment variable that disables it. Your build script is unaffected, so you can keep Turbopack for production builds and only fall back to Webpack for the dev server.
- Is the Turbopack Suspense remount loop a bug in my Zustand store?
- No. The loop is at the React.lazy boundary, above your state management, so merging stores, memoising selectors, or adding equality functions changes nothing. The proof is that the same code is stable under next dev --webpack and in production builds. It is an open Turbopack development-mode issue, not an application bug.
- Can I keep code-splitting and still avoid the Turbopack remount loop?
- Yes. Eager-import the component directly in development and keep React.lazy only for production builds by branching on process.env.NODE_ENV. The production bundle stays split exactly as before, and in development there is no lazy boundary for Turbopack to remount. This is the better workaround when only a component or two is affected, since it keeps Turbopack's dev speed everywhere else.