ChunkLoadError: Your Deploy Broke Everyone's Open Tabs
It’s 4:50pm. You ship a routine deploy, close the laptop, and by 5:15 your error monitoring is showing a spike of ChunkLoadError: Loading chunk 47 failed. Nothing you changed is anywhere near chunk 47. The errors come from dozens of different users, on every browser, and none of it reproduces on your machine.
This chunk load error is the most common JavaScript deploy-time failure I know of, and the cause is almost never the code you just shipped. It’s the tabs that were already open when you shipped it.
What causes a chunk load error in JavaScript apps
Every modern bundler — webpack, Vite/Rollup, esbuild-based setups — code-splits your app into chunks and gives each one a content-hashed filename: chunk-47.a1b2c3.js. Hashing is what lets you serve those files with aggressive, immutable caching. New content, new hash, new URL. It’s a good system.
The failure mode is a timing gap:
- A user loads your app at 2pm. The HTML they receive contains a manifest pointing lazy routes at
chunk-47.a1b2c3.js. - You deploy at 4:50pm. The build produces
chunk-47.d4e5f6.js, and — here’s the trigger — your deploy deletes the old files, because most deploy processes replace the output directory wholesale. - At 5:10pm the user clicks a route they haven’t visited yet. Their still-open tab dutifully requests
chunk-47.a1b2c3.js. - That file no longer exists. The server returns a 404 (or your SPA fallback returns
index.html, which fails to parse as JavaScript — a nastier variant). The dynamicimport()rejects, and the user gets a broken page or a blank screen.
The old tab isn’t wrong. You made a promise in its HTML and then deleted the thing you promised.
A few aggravating variants worth knowing: CDN edge nodes can serve a new HTML that references chunks the edge hasn’t pulled yet (the same bug in reverse), and users on flaky mobile connections produce a low, constant baseline of chunk failures that has nothing to do with deploys. Which is why you need to look at the shape of the errors, not just their existence.
Detecting it: the signature is the spike, not the error
The individual error is unmistakable — ChunkLoadError from webpack, or TypeError: Failed to fetch dynamically imported module from Vite. The diagnosis lives in the pattern:
- Timing: a sharp spike starting minutes after a deploy, decaying over hours as stale tabs get closed. If you overlay deploy markers on your error timeline, the correlation is embarrassing.
- Distribution: many distinct users, one or two occurrences each. A genuine bug in a chunk produces the opposite shape — fewer users, repeated failures.
- Referenced hash: the failing URL contains a hash from the previous build. This is the smoking gun, and it’s worth logging the failed URL for exactly this reason.
If you have session replay with network capture, one recording settles it in ten seconds: you’ll see a user mid-session, a deploy-boundary timestamp, then a 404 on an old-hash chunk URL followed by the error. Same forensic approach as chasing down CORS errors that only happen in production — the network timeline tells you what the stack trace can’t.
The constant-baseline case (spread evenly across time, correlated with poor connectivity, heavy on mobile Safari) is not this bug, and no deploy strategy will fix it. Handle it with the recovery pattern below and move on.
Fix 1: recover gracefully in the client
You can’t prevent every stale tab, so the first fix is making the failure invisible. When a lazy import fails, retry once with a cache-buster; if it still fails, the build is genuinely gone — reload the page so the user picks up fresh HTML.
// lazyWithRetry.ts
import { lazy } from 'react';
export function lazyWithRetry<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>
) {
return lazy(async () => {
try {
return await factory();
} catch {
// One retry for transient network blips
try {
return await factory();
} catch {
// Chunk is gone (deploy happened). Reload once to get fresh HTML.
const key = 'chunk-reload-at';
const last = Number(sessionStorage.getItem(key) ?? 0);
if (Date.now() - last > 10_000) {
sessionStorage.setItem(key, String(Date.now()));
window.location.reload();
}
throw new Error('ChunkLoadError: reload did not resolve');
}
}
});
}
// Usage
const SettingsPage = lazyWithRetry(() => import('./pages/Settings'));
The sessionStorage guard matters. Without it, a user whose network genuinely can’t fetch the chunk gets an infinite reload loop, which is considerably worse than the error you started with. I know because I shipped that version first.
The reload is safe here because it happens at a route transition — the user was navigating anyway, so you’re not destroying form state. Reloading from a global error event handler, by contrast, can eat someone’s half-written support ticket. Keep recovery at the import boundary.
Fix 2: get your cache headers right
Two rules, no exceptions:
- Hashed assets (
/assets/*.js,*.css):Cache-Control: public, max-age=31536000, immutable. The hash changes when content changes; caching forever is correct. - HTML (and any unhashed manifest):
Cache-Control: no-cache. Note thatno-cachedoesn’t mean “don’t cache” — it means “revalidate before using,” which is exactly right. Browsers still get 304s and fast loads; they just never render stale HTML.
The classic misconfiguration is a CDN or nginx default that gives index.html a five-minute TTL. Now even a fresh page load can receive old HTML pointing at deleted chunks, and your spike lasts as long as the TTL. Check the actual response headers in production, not what you think your config says — this is a two-minute check that finds the problem depressingly often.
Fix 3: stop deleting the old build
Cache headers shrink the window; they can’t close it, because open tabs hold their HTML for hours or days. The structural fix is to keep previous builds’ assets available after a deploy.
Content hashing makes this nearly free: old and new files can’t collide, so deploying becomes “add new files” instead of “replace directory.” Concretely:
- Uploading to S3/GCS behind a CDN: sync without the delete flag, then prune objects older than some window (a week is generous) with a lifecycle rule.
- Docker images per release: serve assets from a volume or bucket that outlives any single image, or run the previous release’s asset server behind the same path until it drains.
- Vercel, Netlify and similar platforms already do this — old deployment assets stay resolvable — which is why teams migrating off those platforms are often meeting this bug for the first time.
Keep roughly a week of old assets. A user with a two-week-old tab exists, but the reload prompt from Fix 1 catches them.
Do all three. The client-side retry handles what you can’t control, cache headers stop you from manufacturing new stale tabs, and kept assets mean the tabs that do exist keep working. After we did the full set, chunk errors went from a guaranteed post-deploy spike to a flat trickle of genuine network failures — the kind you can actually ignore.
See the bug the way your user did
LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.
Try LogReplay free