DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

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

Headline: 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. Fix it by keeping old builds addressable with the Next.js deploymentId option or Vercel Skew Protection, and by making the client reload itself once when a mismatch is unavoidable.

I shipped an ordinary deploy and my 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 confirmed skew rather than a build problem for me: the errors clustered within minutes of a deploy timestamp, they only affected sessions older than the deploy, and the failing asset URLs returned 404 while the app itself was 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.

What breaks during deployment skew, layer by layer?

Layer Symptom Why it breaks
Static JS chunks ChunkLoadError, or Failed to fetch dynamically imported module on Vite Content-hashed filenames from the old build were removed from the CDN
RSC payload (App Router) A client-side navigation does a full page load instead of a soft transition The Flight response does not match the build the router expects, so Next.js falls back to a full document navigation
Server Actions Failed to find Server Action in server logs Server Action IDs are build-specific hashes; the old client posts an ID the new build has never seen
Route handlers and REST APIs 400 or 422 responses, or silently wrong data 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 Cached HTML outlives the deploy and never re-resolves asset URLs

The Server Actions row surprised me most. 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 I 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:

const nextConfig = {
  deploymentId: process.env.NEXT_DEPLOYMENT_ID,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

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. 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.

How do I recover gracefully when skew still happens?

Server-side protection reduces skew but never guarantees zero, so the client needs a recovery path:

'use client';

import { useEffect } from 'react';

const CHUNK_ERROR = /Loading chunk [\w-]+ failed|Failed to fetch dynamically imported module/;

export default function GlobalError({ error }: { error: Error }) {
  const isChunkError = error.name === 'ChunkLoadError' || CHUNK_ERROR.test(error.message);

  useEffect(() => {
    if (!isChunkError) return;
    if (sessionStorage.getItem('chunk-reload') === '1') return;
    sessionStorage.setItem('chunk-reload', '1');
    window.location.reload();
  }, [isChunkError]);

  return <p>Loading the latest version…</p>;
}
Enter fullscreen mode Exit fullscreen mode

Clear the chunk-reload 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:

const res = await fetch('/api/version', { cache: 'no-store' });
const { buildId } = await res.json();

if (buildId !== process.env.NEXT_PUBLIC_BUILD_ID) {
  showReloadBanner();
}
Enter fullscreen mode Exit fullscreen mode

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 I 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. I 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 made 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 do I see "Failed to find Server Action" 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.

Q: Should I just 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 my 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.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)