How to Handle Chunk Load Errors After a Deploy
This guide covers an operational side effect of Dynamic Imports and Route-Based Splitting, within JavaScript Bundle Optimization & Code Splitting. Code splitting means the application loads parts of itself later — after the page has been open for minutes or hours. Content-hashed chunk filenames mean every deploy changes the names of changed chunks. Put the two together and a user with a tab open from before the deploy clicks a link, the old runtime requests ProductPage-3f9a.js, and the server returns a 404 (or worse, the HTML of the new index page with a 200 status). The import fails with ChunkLoadError or "Failed to fetch dynamically imported module", and the navigation breaks.
This is not a rare edge case. Sites that deploy several times a day see these errors constantly in error monitoring, often dismissed as noise. For users, it is a click that does nothing — the worst possible interaction, and one INP never records because no paint follows.
Rapid Diagnosis
- Search error monitoring for
ChunkLoadError,Loading chunk,Failed to fetch dynamically imported moduleandImporting a module script failed. Plot them against deploy times. - Check what the server returns for a missing chunk. If a request for a nonexistent
.jsreturns your SPA'sindex.htmlwith status 200, the browser reports a MIME type error instead — and caches may store the HTML under the JS URL. - Check asset retention. Does your deploy delete the previous build's assets, or upload to a fresh bucket?
- Check service worker behaviour. A service worker serving an old app shell can extend the window during which old chunk names are requested.
Root Cause Analysis
1. Old assets deleted on deploy. Atomic deploys that replace the whole asset directory remove files old tabs still reference.
2. SPA fallback rewrites. Hosting rules that rewrite every unknown path to index.html turn a clean 404 into a confusing MIME error and can poison caches.
3. No retry or recovery in the app. A failed import propagates as an unhandled error; the router aborts navigation and the user is stuck.
4. Long-lived tabs. Dashboards and SPAs are left open for days; any deploy in that window can break them.
Step-by-Step Resolution
1. Keep previous builds' assets available
Deploy hashed assets additively: upload new files, never delete recent old ones. Expire assets older than your longest realistic tab lifetime (often 7–30 days) with a lifecycle rule.
# Upload only new hashed files; keep existing ones (no --delete).
aws s3 sync dist/assets s3://my-site-assets/assets \
--cache-control "public, max-age=31536000, immutable"
# HTML is uploaded separately with a short TTL so new visitors get the new build.
# trade-off: retaining old assets costs storage and keeps old code reachable.
# Expire them after a fixed window with a bucket lifecycle rule rather than never.
Expected outcome: old tabs can still load the chunks they reference; most errors disappear.
2. Return real 404s for missing assets
Exclude asset paths from the SPA fallback so a missing chunk gets a 404 with no body, not index.html.
location /assets/ {
try_files $uri =404; # never fall back to index.html for assets
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / { try_files $uri /index.html; }
# trade-off: none worth mentioning for assets — but make sure the CDN does not
# cache 404s for long, or a chunk uploaded seconds late stays "missing".
Expected outcome: failures are clean and diagnosable, and caches are never poisoned with HTML.
3. Retry once, then reload to the new version
When an import fails, retry after a short delay (transient network errors are common on mobile). If it fails again, the user's runtime is outdated: reload the page at the target URL, once.
export async function importWithRecovery(load, targetUrl) {
try { return await load(); }
catch (err) {
await new Promise((r) => setTimeout(r, 500));
try { return await load(); }
catch {
const key = 'chunk-reload:' + targetUrl;
if (!sessionStorage.getItem(key)) { sessionStorage.setItem(key, '1'); location.assign(targetUrl); }
throw err;
}
}
}
// trade-off: a reload discards in-memory state (unsaved form input). Save
// drafts before reloading, or show a banner asking the user to refresh instead
// of reloading automatically on pages where state matters.
Expected outcome: the rare user who still hits a missing chunk lands on the new version of the page they wanted, instead of a dead click.
4. Hook recovery into the router
Vue Router exposes router.onError; React Router data routers expose errorElement; Vite emits a vite:preloadError event on window when a dynamic import's preload fails.
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault(); // stop the error propagating
location.reload();
});
router.onError((err, to) => {
if (/Failed to fetch dynamically imported module|ChunkLoadError/.test(String(err))) location.assign(to.fullPath);
});
// trade-off: blanket reloads on any import error can loop if the error is
// not version skew (a genuinely broken chunk). Guard with a session flag as above.
Verification
Deploy to staging, keep a tab open from the previous build, deploy again, then navigate in the old tab to a route whose chunk changed. With retention, the old chunk should load. Delete it manually to simulate expiry and confirm the reload-once recovery lands on the new page. In production, chart chunk load errors per session against deploy times; after the fix the post-deploy spikes should flatten.
Worked Example: A Dashboard Deployed Ten Times a Day
An analytics dashboard deployed frequently and replaced its asset folder on each release. Users kept tabs open all day; error monitoring logged thousands of ChunkLoadErrors daily, and support received "buttons stop working in the afternoon" complaints. The team switched to additive uploads with a 14-day lifecycle rule, excluded /assets/ from the SPA fallback, and added the vite:preloadError handler with a once-per-session reload guard. Daily chunk errors fell by more than 99%, and the remaining handful corresponded to tabs older than two weeks — which now reloaded cleanly to the current build.
Common Mistakes
- Treating chunk errors as noise. Each one is a user whose click did nothing.
- Reloading without a guard. A genuinely broken chunk then causes an infinite reload loop.
- Caching index.html for long. New visitors keep receiving the old shell and its old chunk names, extending the skew window.
- Forgetting the service worker. A precached old shell keeps referencing old chunks; update the service worker promptly — see handling service worker updates with skipWaiting.
FAQ
Can I avoid the problem by not hashing filenames?
Stable filenames make old tabs load new code into an old runtime, which is worse: mismatched module versions produce subtle bugs. Hashing plus retention is the correct combination; it keeps each runtime consistent with the chunks it expects.
Should I prompt users to refresh when a new version is deployed?
It is a good complement. Poll a small version.json (or listen for a service worker update) and show a non-blocking "A new version is available" banner. Users refresh at a moment that suits them, before version skew can break anything.
Do CDNs need special configuration?
Make sure 404s for asset paths are cached briefly or not at all, and that purges on deploy target HTML rather than hashed assets. Purging old hashed assets defeats retention. Invalidating immutable hashed assets safely covers the caching side.
Related
- Fixing stale HTML after a deploy — the HTML side of version skew.
- Code-splitting Vue Router routes — where router error hooks fit.
- Lazy-loading React components with Suspense — error boundaries around lazy components.