Fixing ChunkLoadError After a New Deploy
A user opens your app in a browser tab before lunch. The team ships a deploy at 1 p.m. At 2 p.m., the user — who never refreshed the tab — clicks a nav link into a lazily-loaded section for the first time that session. The console shows:
Uncaught (in promise) ChunkLoadError: Loading chunk 5 failed.
(error: https://cdn.example.com/assets/dashboard.4f2a91.chunk.js)
at __webpack_require__.f.j (bootstrap:23:31)
at Array.reduce (<anonymous>)
at __webpack_require__.e (bootstrap:19:67)
Or, on a Vite/Rollup build, the equivalent surfaces as:
Failed to fetch dynamically imported module:
https://cdn.example.com/assets/Dashboard-C8x1lq.js
Either way, the user sees a blank panel, a spinner that never resolves, or an error boundary — and support gets a ticket that says “the dashboard is broken,” with no obvious repro from a fresh tab.
Root cause: the running tab’s bootstrap is older than the CDN
The JavaScript that’s currently executing in that tab was loaded at 1 p.m., before the deploy. Baked into that already-running bootstrap is a chunk-to-URL manifest — for Webpack, the runtime map inside __webpack_require__; for Vite/Rollup, the resolved import specifiers emitted into the entry module. That manifest says chunk 5 lives at dashboard.4f2a91.chunk.js. The 1 p.m. deploy replaced dist/ wholesale: dashboard.js changed, so it now hashes to a different filename, and the build’s clean step (or the CDN’s origin sync) removed the old file entirely. When the user finally navigates into the dashboard at 2 p.m., the tab asks for a file that is simply gone.
This is a distinct failure from the hash-instability problem covered in deterministic chunk hashing for long-term caching. That page is about preventing unrelated files from changing their hash on every deploy; this page is about what happens when a file’s hash legitimately changes — which, on any deploy with real code changes, some files always will. Vendor and shared chunks isolated by vendor chunk isolation and third-party management rarely trigger this, precisely because they change so infrequently; app-code chunks, updated on nearly every release, are where this bites hardest. Deterministic module IDs and an extracted runtime chunk reduce how many files change per deploy, but they cannot eliminate the fact that some files change, and that stale tabs will keep asking for the old ones until they’re refreshed.
Fix 1: wrap dynamic imports in a retry-then-reload helper
The cheapest fix that ships in application code, no infrastructure change required, is catching the failed import() and escalating to a full page reload after a couple of retries — retries absorb transient network blips, and the reload picks up the current index.html, which references the current, correct chunk manifest.
// chunk-retry.js — wraps a dynamic import() with retry + forced reload fallback
function importWithRetry(factory, retries = 2, delay = 800) {
return factory().catch((error) => {
const isChunkFailure = /ChunkLoadError|Failed to fetch dynamically imported module/
.test(error && error.message || '');
if (!isChunkFailure) throw error; // don't mask unrelated errors
if (retries === 0) {
// The file is genuinely gone, not a transient blip. Reload picks up
// the current index.html and its up-to-date chunk manifest instead
// of continuing to trust the stale in-memory one.
window.location.reload();
return new Promise(() => {}); // suspend; a reload is already in flight
}
return new Promise((resolve) => setTimeout(resolve, delay)).then(() =>
importWithRetry(factory, retries - 1, delay * 2)
);
});
}
// Usage with React.lazy
const Dashboard = React.lazy(() => importWithRetry(() => import('./pages/Dashboard')));This handles the failure gracefully once it happens, but the user still eats one failed navigation and a forced reload. The next two fixes reduce how often that happens at all.
Fix 2: check the deployed version before navigating
A version-aware guard checked on each client-side route transition can catch a stale tab before it attempts a doomed chunk fetch, upgrading a hard error into a clean, expected reload.
// version-guard.js — checked before client-side route transitions
let buildVersion = window.__BUILD_VERSION__; // injected into index.html at build time
async function ensureCurrentVersion() {
const res = await fetch('/version.json', { cache: 'no-store' });
const { version } = await res.json();
if (version !== buildVersion) {
// A newer deploy exists. Reload now, before the router attempts to
// lazy-load a chunk the stale bootstrap has no record of.
window.location.reload();
return false;
}
return true;
}version.json must be served with Cache-Control: no-store — if it’s cached, the guard checks against a stale value and never fires. The same rule applies to index.html itself; only the hashed chunk files should carry the long immutable cache lifetime.
Fix 3: retain previous releases on the CDN
Neither runtime fix helps if the old file is simply deleted the moment a new one is deployed. Configure the deploy pipeline to prune only releases older than a retention window, not the immediately preceding one:
# deploy.sh — retain the previous 5 releases so in-flight tabs can still
# fetch chunks referenced by their already-loaded bootstrap
aws s3 sync dist/ "s3://cdn-bucket/releases/$BUILD_ID/" \
--cache-control "public,max-age=31536000,immutable"
# Prune anything older than the 5 most recent releases — never the
# current or immediately preceding ones
aws s3 ls "s3://cdn-bucket/releases/" | awk '{print $2}' | sort -r \
| tail -n +6 | xargs -I{} aws s3 rm --recursive "s3://cdn-bucket/releases/{}"Choosing between Webpack’s splitChunks and Vite’s Rollup-based chunking affects how many distinct chunk files a given release produces, which in turn affects how much storage a retention window costs — a trade-off covered in Webpack 5 vs Vite 5 splitChunks — when to choose which. Teams running a stable vendor boundary via Vite’s manualChunks typically find retention cheap, since the bulk of unchanged bytes live in vendor chunks that don’t get re-emitted every release in the first place.
Fix 4: align the service worker
If a service worker is in play, it can extend the stale window well past normal HTTP caching by serving a cached index.html or navigation response that itself points at deleted chunk hashes — even after the CDN retention window above has expired. The service worker’s cache name and precache manifest must be tied to the build, and old caches must be evicted on activation, as covered in invalidating service worker cache after deploy. Skipping this step is the single most common reason teams see ChunkLoadError persist for days after they believed they’d fixed it with the retry wrapper alone.
Step-by-step verification
-
Reproduce locally. Build the app, load a lazy route in the browser, then delete or rename that route’s chunk file in
dist/. Navigate to the route again in the same tab (without reloading) and confirm you see the exactChunkLoadErroror “Failed to fetch dynamically imported module” in the console. -
Verify the retry wrapper degrades gracefully. With the retry helper in place, repeat step 1 and confirm the Network panel shows two retried requests before a full page reload fires — not an infinite spinner and not an unhandled promise rejection.
-
Verify the version guard fires before navigation. Deploy a real change, bump
__BUILD_VERSION__, then in an already-open tab trigger a route change. ConfirmensureCurrentVersion()detects the mismatch and reloads before any chunk request is attempted. -
Confirm CDN retention holds. After a real deploy,
curl -Ia chunk URL from the previous release and confirm it still returns200, not404, for the duration of your retention window. -
Confirm the service worker doesn’t undercut the above. With DevTools’ Application panel open, force-deploy twice in a row and verify the active service worker’s cache name changed and the old cache was deleted — not silently serving a stale
index.htmlfrom a previous deploy.
Edge cases and gotchas
The reload loop. If version.json or index.html itself is served from a cache that hasn’t picked up the new deploy yet, the version guard can reload into another stale page, which reloads again. Always serve both with Cache-Control: no-store (or no-cache with revalidation), never immutable — only hashed asset files should carry the long-lived cache header.
Multi-region deploy skew. A CDN with multiple edge points of presence does not update atomically. During the propagation window — often 30–90 seconds, sometimes longer on a cold cache — some edges serve the new index.html while others still serve the old one, meaning a user can load a “new” shell that references a chunk not yet replicated to the edge that serves the chunk request. Retention windows and the retry wrapper both help absorb this; treat any single-digit-second ChunkLoadError immediately after a deploy as expected noise, not a regression.
SSR shells deployed on a different schedule than static assets. In server-rendered apps, the HTML shell is generated per-request by a server process that may deploy on a separate pipeline from the CDN hosting the JS assets. If the two drift out of sync — even briefly during a blue/green cutover — the server can emit a chunk manifest reference that the asset host hasn’t caught up to yet, producing the same error for reasons that have nothing to do with an old browser tab.
FAQ
What does “Loading chunk 5 failed” actually mean?
The number is Webpack’s internal chunk ID, not a filename. The runtime bootstrap that’s already running in the browser tab is trying to fetch the file it believes corresponds to chunk 5 — a hashed filename baked into that bootstrap at the time the page was first loaded. If a deploy has happened since, that exact file may no longer exist on the server or CDN, and the request returns a 404 or network error, which the runtime surfaces as ChunkLoadError.
Will deterministic chunk hashing prevent ChunkLoadError?
No. Deterministic hashing keeps a chunk’s filename stable across deploys only when that chunk’s content hasn’t changed — it does nothing to protect chunks whose content did change, and every deploy that ships a real code change still deletes the previous filename for that chunk. ChunkLoadError is a distribution and retention problem, not a hashing-determinism problem; fixing it requires retaining old files and handling the fetch failure at runtime, not just stabilizing hashes.
How long should I retain old chunk files on the CDN?
Long enough to cover the longest realistic session on an old tab — typically the last 5 to 10 releases, or a fixed window of several days, whichever is longer for your deploy cadence. Teams that ship multiple times a day should lean toward a time-based retention window (e.g. 72 hours) rather than a release-count window, since release count alone can expire too quickly during a high-frequency deploy day.
Does a service worker make ChunkLoadError worse or better?
It can make it worse if left unmanaged, because a service worker can serve a cached index.html or navigation response that itself points at deleted chunk hashes, extending the stale window well past normal browser caching. Configured correctly — with skip-waiting and old-cache cleanup wired to each deploy — it becomes part of the fix instead, since it can proactively evict the old manifest as soon as a new one is available.
Related
- Deterministic Chunk Hashing for Long-Term Caching — parent cluster on keeping chunk filenames stable across deploys
- Stabilizing Chunk Hashes to Maximize Cache Hits — sibling page on preventing unrelated vendor-hash churn in the first place
- Invalidating Service Worker Cache After Deploy — aligning service worker cache eviction with each deploy
- Configuring Vite manualChunks for Vendor Isolation — reduces how many files change per deploy in the first place
- Webpack 5 vs Vite 5 splitChunks — When to Choose Which — how the chunking model affects retention cost and blast radius