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.

How a deploy breaks an open tab Sequence showing a tab loaded before a deploy requesting a lazy chunk that the deploy removed, receiving a 404 and failing. How a deploy breaks an open tab Old tab CDN Origin GET Product-3f9a.js cache miss 404 (deleted on deploy) ChunkLoadError

Rapid Diagnosis

  • Search error monitoring for ChunkLoadError, Loading chunk, Failed to fetch dynamically imported module and Importing a module script failed. Plot them against deploy times.
  • Check what the server returns for a missing chunk. If a request for a nonexistent .js returns your SPA's index.html with 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.

Chunk load errors per 10k sessions after a deploy Bar chart of chunk load errors per ten thousand sessions in the hours after a deploy, under three mitigation levels. Chunk load errors per 10k sessions after a deploy Old assets deleted, no recovery 41 Old assets kept 7 days 3 Kept + reload-on-failure 0.4

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.

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

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

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

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

Layers of protection against version skew Four layers that together prevent chunk load errors after deploys from breaking user navigation. Layers of protection against version skew Additive deploys with retention Old hashed assets stay available for days Real 404s for missing assets No SPA fallback HTML served as JavaScript Retry then reload once Transient failures retried; version skew reloads to the new build Proactive update prompts Detect a new version and offer a refresh before it breaks 1 2 3 4

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.