Why old browser tabs request missing chunks—and how react-lazy-chunk-recover handles the failure without a reload loop.
A user leaves a React app open during a deployment. Hours later, they visit a lazy-loaded route for the first time. Their old tab requests a chunk the new deployment no longer serves. The route fails, even though it works in a fresh tab.
For example, the old tab might request Reports.a1b2.js after the server has switched to Reports.c3d4.js. The hashes are illustrative; the missing-file failure is the point. Vite documents this deployment failure mode.
I built and published react-lazy-chunk-recover to handle this case inside a React.lazy component loader. It recognizes likely chunk-load errors, allows one guarded reload, and passes a repeat failure to your Error Boundary. The package includes TypeScript declarations, lists React as a peer dependency, and has no direct runtime dependencies.
Why the usual React.lazy setup cannot recover on its own
React.lazy waits until the first render to call your loader. While the import is pending, <Suspense> shows a fallback. If the import rejects, React sends the error to the nearest Error Boundary. React also caches the loader promise and its result, so another render of the same lazy component does not simply start a new import. React describes each behavior in its lazy reference.
An Error Boundary can explain the failure. It cannot, by itself, update the old tab's asset references. A reload may do that by fetching the current HTML and application bundle—but only if your deployment serves them correctly. An unconditional reload can trap the user when the error persists.
The recovery code therefore needs two decisions: Is this likely a chunk-load error? And has this tab already tried to recover?
How react-lazy-chunk-recover works
The package recognises dynamic-import messages such as Failed to fetch dynamically imported module and ChunkLoadError. For a matching failure, it reads a sessionStorage guard. If no guard exists, it writes one and calls window.location.reload(). If a guard already exists, it throws the error to React. A successful load clears the guard, so a later deployment can get its own recovery attempt.
If the browser blocks sessionStorage, the package throws the error instead of starting a reload it cannot limit. During server-side rendering, it skips browser storage and page reloads; it rethrows the original import error.
Let's use it on a lazy route
Install the package:
npm install react-lazy-chunk-recover
Then replace the React.lazy call. Keep the lazy component at module scope, as React recommends:
import { Suspense } from 'react';
import { lazyWithRetry } from 'react-lazy-chunk-recover';
const Reports = lazyWithRetry(() => import('./Reports'), {
moduleKey: 'reports',
});
export function ReportsRoute() {
return (
<Suspense fallback={<p>Loading reports…</p>}>
<Reports />
</Suspense>
);
}
moduleKey gives this lazy loader its own guard. Use a stable, unique value for each logical lazy-loaded component—for example, reports and settings. Keep the value the same across renders and deployments; do not generate a random key or reuse one key for unrelated components. Without moduleKey, lazy components share the default guard, and one successful load can clear another component's recovery attempt.
Keep your Error Boundary around the route. <Suspense> handles the pending import; the Error Boundary handles a non-chunk error or a failed recovery. lazyWithRetry does not export an Error Boundary or replace the one your app already uses.
This wrapper targets components loaded through React.lazy. It is not a replacement for React Router's separate route-module lazy API or for every dynamic import in an application.
Configure retries for the failure you actually see
The default retries value is 0. That is deliberate: a second request for a deleted hashed file usually gets the same 404.
If your telemetry also shows brief network interruptions, allow a small number of in-place retries:
const Reports = lazyWithRetry(() => import('./Reports'), {
moduleKey: 'reports',
retries: 2,
retryDelayMs: 250,
});
The loader makes up to two extra attempts, with the wait doubling after each attempt. It only enters the reload path if a recognised failure remains. Start with the default; add retries when your production errors justify the extra delay.
For an application that publishes a trusted, current asset manifest, getFreshModule offers one alternate import attempt before the reload. Your application must supply the URL and verify that the returned module is compatible with the running app. The package cannot derive a new hashed URL from the old import error. I would not enable that path without a defined manifest contract and a test that spans two builds.
Test it like a real deployment
Use a staging environment, not production. In this test, build A is the version already running in an open tab, and build B is the newer version on the server. Pick one lazy route, such as /reports. You want to see whether the old tab can reach the working route after its old chunk disappears.
First run: recovery succeeds
- Deploy build A. Open the app and leave that tab on a different route; do not open
/reportsyet. Check the Network panel to make sure the route's chunk has not already been prefetched. - Deploy build B without refreshing the old tab. In staging, make build A's
/reportschunk unavailable. If your host retains old files, block that exact old chunk URL in your browser's request-blocking tools instead. - In the old tab, open
/reports. The request for build A's chunk should fail. Expect one full-page reload, followed by the/reportspage from build B. - Use the page, not just its heading. Confirm its important backend request or action still works with build B.
Second run: recovery cannot finish
Start again with a clean browser session and repeat the setup, but make build B's /reports chunk fail too. Expect at most one automatic reload. After the second failure, your Error Boundary should appear instead of another reload.
Finally, refresh /reports directly to confirm your server serves the app on a nested URL. In automated tests, also simulate blocked sessionStorage; the package should throw the error rather than reload without a guard.
Record the chunk-load failure, the attempted reload, and whether the route works against the current backend afterwards. The reload alone does not confirm recovery.
Plan for deployment compatibility too
The wrapper handles a failure after it reaches a lazy component. Your release process also needs to decide whether an old client may keep running:
- Retain old hashed assets only when the old client remains compatible and safe. An incompatible API change or urgent security fix may require old tabs to update instead.
- Let browsers revalidate the HTML entry point. Vite recommends
Cache-Control: no-cachefor HTML in this scenario. - If you use Vite, review its
vite:preloadErrorevent for dynamic-import failures outside this wrapper. - Confirm that a full-page reload works on every client-side route.
If a release requires every open tab to update, use an explicit app-version or update policy. This package only reacts when a wrapped lazy import fails; it cannot force an update while old chunks continue to load. If the current build still cannot load the route, the package passes the failure to your Error Boundary instead of refreshing forever.
Final thoughts
A deployment can change more than file names. It can change API contracts, fix a critical bug, or remove vulnerable client code. Keeping old chunks available may avoid a 404 while leaving an old tab on a version you no longer want running.
That is why I built react-lazy-chunk-recover: when a wrapped lazy import fails, the tab gets one guarded chance to load the current build. If it still fails, your Error Boundary takes over. The package does not replace a version policy for releases that must retire every old client.
If you face stale-chunk failures, try the package on one lazy route and reproduce the two-build deployment described above. Check the route's backend interactions after the reload, not just its loading state. A reload is an attempt; a working route on the current build is recovery.
I would be interested to hear how your app handles breaking deployments and what chunk-load message your browser or bundler produces. If you try the package, share those details and whether the route worked after the reload. That feedback can help shape the next release.
ENJOY YOUR CODING! 🚀

Top comments (1)
Nice write-up, the sessionStorage guard is the part most hand-rolled versions forget. Two small additions from the deployment side. If you use Vite, it fires a
vite:preloadErrorevent onwindowwhen a dynamic import's preload fails, so you can hook the same guarded reload there without wrapping every lazy loader. And on the hosting side, it helps to keep the previous build's hashed assets around for a day or two instead of deleting them on each deploy, because then most stale tabs keep working and the reload path only triggers for the long-lived ones. Maybe also give the guard a short expiry (say 10 seconds) so a tab that legitimately hits a second deployment later isn't blocked forever.