Skip to main content
Why a Deploy Breaks Every Open Tab: Field Notes on ChunkLoadError, Deployment Skew, and Versioned Clients

Why a Deploy Breaks Every Open Tab: Field Notes on ChunkLoadError, Deployment Skew, and Versioned Clients

September 20, 2026
Frontend Engineering
9 min read

A ChunkLoadError after a deploy is deployment skew, not a bundler bug: a browser tab still running build A requests a content-hashed chunk or a Server Action ID that only existed in build A, and the CDN now serves only build B. The fix is to keep old builds addressable with the Next.js deploymentId option or Vercel Skew Protection, and to make the client reload itself once when a mismatch is unavoidable.

We shipped an ordinary deploy and the error tracker lit up with ChunkLoadError: Loading chunk 4821 failed. Nothing in the diff touched routing, bundling, or dynamic imports. Every error came from a session that had loaded the page before the deploy finished. That is the whole story of deployment skew: the server moved forward, and browsers that were mid-session did not.

Key takeaways

ChunkLoadError means a requested JavaScript chunk returned a 404, almost always because a new deploy replaced the content-hashed filenames that an already-open tab still references.

Deployment skew is a client/server version mismatch — an old client talking to a new server — and it breaks four distinct layers: static chunks, RSC payloads, Server Action IDs, and API request shapes.

Next.js supports a deploymentId option in next.config.ts, which appends a ?dpl= query parameter to asset and RSC requests so infrastructure can route an old client back to its own build.

Vercel Skew Protection keeps previous deployments servable and pins each browser session to the build it started on, which eliminates the error class instead of hiding it.

Always ship a client-side recovery path: catch chunk-load failures in an error boundary and force exactly one window.location.reload(), guarded by a session flag so a genuinely broken build cannot cause a reload loop.

What actually causes ChunkLoadError after a deploy?

A ChunkLoadError is thrown when the browser requests a lazily loaded JavaScript bundle and receives a 404 or an HTML error page instead of JavaScript. Next.js, Vite, and webpack all name those bundles with a content hash — something like 4821-a91f3c2e.js — precisely so the files can be cached immutably forever. When you deploy, every changed file receives a new hash and the previous filenames stop existing at the origin.

The HTML already delivered to an open tab hardcodes the old hashes. The moment the user clicks a link that triggers next/dynamic, React.lazy, or a route-level code split, the browser asks for a file that is gone. The immutable Cache-Control header that makes hashed assets fast is exactly what makes this situation fragile: immutability guarantees the browser will never re-check the filename it was handed.

Three signals tell you this is skew rather than a broken build. The errors cluster within minutes of a deploy timestamp. They only affect sessions that started before that deploy. And the failing asset URLs return 404 while the application itself is completely healthy for anyone loading it fresh.

Why doesn't telling users to hard refresh count as a fix?

A hard refresh does resolve the error for the person who complains, and that is the trap. Because the workaround is trivial, the bug gets classified as cosmetic while it keeps costing real sessions. Nobody files a ticket for a checkout that silently failed to open a payment step.

The people hit hardest are the ones you least want to break: users on a long-lived dashboard tab, users with a half-filled multi-step form, and mobile users whose tab was backgrounded for an hour and restored from memory. Every one of them loses in-progress state when the only recovery is a manual reload. Deploy frequency makes it worse — the more often you ship, the wider the window in which some tab is running an old build.

What breaks during deployment skew, layer by layer?

Deployment skew is not one failure mode, it is four, and each one surfaces differently in your logs.

Static JS chunks: the symptom is ChunkLoadError, or Failed to fetch dynamically imported module on Vite, because content-hashed filenames from the old build were removed from the CDN.

RSC payload in the App Router: a client-side navigation does a full page load instead of a soft transition, because the Flight response does not match the build the router expects and Next.js falls back to a full document navigation.

Server Actions: the server logs show Failed to find Server Action, because Server Action IDs are build-specific hashes and the old client posts an ID the new build has never seen.

Route handlers and REST APIs: you get 400 or 422 responses, or silently wrong data, because the old client sends the old request shape against a changed contract.

Service worker or cached HTML: a stale shell keeps requesting assets that are already dead, because cached HTML outlives the deploy and never re-resolves asset URLs.

The Server Actions case surprises most teams. A Server Action ID is a hash generated at build time, so it is inherently version-bound. A dashboard tab left open across two deploys will post an action ID that simply does not exist server-side anymore.

How do we stop skew at the source with deploymentId and Skew Protection?

The Next.js deploymentId option tags every asset and RSC request with the build that produced it. You set it in next.config.ts by assigning process.env.NEXT_DEPLOYMENT_ID to the deploymentId key of the exported config object.

With deploymentId set, Next.js appends a ?dpl= query parameter to script and RSC requests. That parameter is a routing key, not a fix on its own — it only helps if your infrastructure can actually serve the older build it names.

On Vercel, Skew Protection is the piece that makes the key useful: it keeps previous deployments servable and pins a browser session to the deployment it started on, so an old client keeps talking to the server that understands it. You enable it per project and choose how long the protection window lasts; match that window to your longest realistic session, not to your deploy interval.

Self-hosting, the equivalent is deliberate build retention. Keep the .next/static directory from the last few builds and serve the union of them from your asset origin, or run blue/green with the previous container alive long enough for in-flight sessions to drain. Both approaches cost storage or compute; both are cheaper than the sessions you are currently losing.

How do we recover gracefully when skew still happens?

Server-side protection reduces skew but never guarantees zero, so the client needs a recovery path. The pattern we settled on is a global error boundary that checks whether error.name equals ChunkLoadError or the message matches a chunk-failure pattern, then triggers exactly one window.location.reload() inside a useEffect, guarded by a sessionStorage flag.

Clear that flag on a successful app mount, otherwise a user who hits one chunk error will never get an automatic recovery again in that tab. Reloading without a guard is the single most common way this pattern goes wrong: if the asset is missing for a real reason, an unguarded boundary reloads forever.

The friendlier variant is to tell users before anything breaks. Bake the build ID into the client at build time as NEXT_PUBLIC_BUILD_ID, fetch a small /api/version endpoint with cache set to no-store, and show a reload banner when the returned buildId differs from the baked-in one.

Poll on an interval and on the visibilitychange event, so a tab that was backgrounded for an hour checks immediately when the user returns to it. That timing matters more than the interval length, because backgrounded tabs are where the longest skew windows live.

How do we keep API contracts skew-safe?

Treat every API change as something an old client will call, because for the length of your skew window it will. The rule is expand-and-contract: add the new field or endpoint in one deploy, migrate the client in the next, and only remove the old shape in a third. Never rename a field and its only consumer in the same release.

For clients that stay open for hours, a stable route handler is more skew-tolerant than a Server Action, because the route handler's URL is part of your API contract while a Server Action's ID is a build artifact. We still use Server Actions for form submissions on short-lived pages; for a long-running dashboard's background writes, an explicit route handler survives deploys without any special infrastructure.

One measurement habit makes all of this legible: send the client build ID as a request header on every call and log it alongside the server build ID. Skew stops being an anecdote and becomes a number you can watch after each release.

FAQ

Q: Is ChunkLoadError caused by a bad build?

A: Rarely. If fresh page loads work and only pre-deploy sessions fail, the build is fine and you are seeing deployment skew. A genuinely broken build fails for new visitors too.

Q: Does setting deploymentId in next.config.ts fix skew by itself?

A: No. deploymentId adds a ?dpl= parameter that identifies which build a request belongs to. It only prevents errors if your hosting can still serve that older build, which is what Vercel Skew Protection or self-hosted build retention provides.

Q: Why does Failed to find Server Action appear only after deploying?

A: Server Action IDs are hashes generated at build time. A tab loaded on the previous build posts an ID the new build never generated, so the lookup fails. Skew protection or a stable route handler avoids it.

Q: Should we disable immutable caching on JavaScript chunks?

A: No. Immutable caching on content-hashed files is correct and is what makes repeat visits fast. The problem is deleting old hashes too early, not caching them aggressively.

Q: How long should a skew protection window be?

A: Set it to your longest realistic session, not your deploy cadence. For a dashboard people leave open all day, hours is the right order of magnitude; for a marketing site, minutes is usually enough.