Nowy standard. Twoje centrum tworzenia inteligentnego, szybkiego i zrównoważonego oprogramowania AI
Table of Contents
When a dynamically loaded module fails to load in production, React.lazy() throws, and without handling, the app crashes for that user. In a large-scale web app, this failure mode is common and predictable.
Every new deployment changes chunk file hashes, and users who still have an older version of the app open in their browser try to load URLs that no longer exist. The fix is a small wrapper around React.lazy() that retries the import a few times, reloads the page once if the retries fail, and uses a sessionStorage flag so it can never loop.
Below is the code, what it changed in our app, and what to check before you ship it.
Executive Summary
React.lazy()enables code splitting, but when a dynamic import fails, it throws, and without handling, that error crashes the app.The most common trigger is a deployment. New Webpack/Vite chunk hashes mean users with an older app version open in their browser request chunk URLs that no longer exist, producing a
ChunkLoadErroror a failed dynamic import.The fix is a
retryLazy()wrapper that adds automatic retries with a configurable delay, one page reload, and asessionStorageflag that prevents reload loops.In the production app where we shipped it, crashes dropped by 92%. The wrapper is about 45 lines with no dependencies.
Do You Need a Custom Retry Wrapper, or Is There a Simpler Fix?
ChunkLoadError is a structural side effect of hashed build filenames meeting long-lived browser sessions. Every deployment invalidates the previous build's chunk URLs; users with the app open across that window request files that no longer exist.
Left unhandled, that turns a routine release into a support ticket and a churn risk: a blank screen or crash the user can't work around, recurring with every release on a continuously deployed application.
Four realistic paths exist for handling ChunkLoadError:
Option | What it takes | What you get | What you give up |
|---|---|---|---|
Do nothing (default behavior) | No work | Nothing changes | Every deploy window becomes a crash risk for stale sessions |
Custom retry wrapper (this article's approach) | About 45 lines, no dependencies | Automatic, configurable retries plus one reload before falling back to an error boundary, across Webpack and Vite | A small amount of code to maintain |
Existing library, e.g., react-lazy-retry | One dependency | A pre-built Retry button for failed lazy components | No automatic retries or reload; no release since 2019 |
Bundler-native handling, e.g., Vite's vite:preloadError | One event listener, no wrapper | Built-in detection of failed dynamic imports | You write the handling yourself (Vite's docs example simply reloads the page); Vite-only, no built-in Webpack equivalent |
For an app already paying that cost every release, the wrapper wins: it retries automatically, works with both bundlers, and takes about 45 lines with no dependency risk. If you're on Vite and only care about the deployment case, a vite:preloadError listener that reloads the page may be all you need.
What Is React.lazy()?
React.lazy() is a React feature that lets you load components only when they are needed. By splitting your app into smaller chunks and loading them on demand, you can reduce the initial bundle size, speed up loading and improve the overall user experience. This is part of React's standard approach to performance optimization.
The React.lazy() feature enables code splitting: the codebase is divided into multiple bundles that can be loaded independently. When a user navigates to a part of the app that requires a specific component, React.lazy() dynamically imports the component then, rather than loading it with the initial bundle. This significantly improves initial loading time.
React.lazy() has been available since React 16.6. The implementation in this post works without modification in React 17, 18, and 19. React 19.3 is the current release as of November 2026; the release list on GitHub shows the latest patch.
In the Next.js App Router, Server Components are code split automatically, so lazy loading in the App Router applies to Client Components. You can use either React.lazy() or next/dynamic, which combines React.lazy() and Suspense (Next.js docs). The retry wrapper relies on window and sessionStorage, so use it only in files marked "use client".
Why Does React.lazy() Fail in Production?
Sometimes, dynamically imported modules fail to load because of network issues, temporary server downtime, or a new deployment. When this happens, users may experience app crashes, rendering the application unusable.
The most common failure scenario in production is a new deployment: when a new version of the app is deployed, the chunk file names change (because Webpack and Vite hash filenames by content). Users with an older version of the app open in their browser may still try to load the old chunk URLs, which no longer exist on the server. The result is a ChunkLoadError or Failed to fetch dynamically imported module error, and, without a retry mechanism, the app crashes.
The fix is to catch the failed import and try again before letting the error crash the app.
How Do You Add Retry Logic to React.lazy()?
A custom retry mechanism creates a wrapper function that retries module imports when errors occur with React.lazy(). This function performs four main tasks:
Attempt to import the module using the provided import function.
Catch any errors that occur during the import process.
Retry the import process a specified number of times, with a delay between each attempt.
Perform a full-page reload if all retries fail, and store a flag in
sessionStorageto prevent infinite reloading.
Here's the code for the custom retry mechanism:
// utils/retryLazy.js
import React from "react";
const retryImport = (importFn, moduleName, retriesLeft = 3, delay = 1000) => {
return new Promise((resolve, reject) =>
importFn()
.then((module) => {
// Clear the reload flag so a later failure in this tab can reload again
if (typeof window !== "undefined") {
sessionStorage.removeItem(`reload_${moduleName}`);
}
resolve(module);
})
.catch((error) => {
setTimeout(() => {
console.log(`Retrying... Retries left: ${retriesLeft}`);
if (retriesLeft === 1) {
const reloadKey = `reload_${moduleName}`;
const hasReloaded = sessionStorage.getItem(reloadKey);
if (!hasReloaded) {
sessionStorage.setItem(reloadKey, "true");
window.location.reload();
} else {
sessionStorage.removeItem(reloadKey);
reject(error);
}
return;
}
retryImport(importFn, moduleName, retriesLeft - 1, delay).then(
resolve,
reject
);
}, delay);
})
);
};
const retryLazy = (importFn, moduleName, retriesLeft = 3, delay = 1000) => {
return React.lazy(() =>
retryImport(importFn, moduleName, retriesLeft, delay)
);
};
export default retryLazy;To use this custom retry mechanism, wrap your dynamic imports with retryLazy() the same way you would use the standard React.lazy():
// App.js
import React, { Suspense } from "react";
import retryLazy from "./utils/retryLazy";
const MyComponent = retryLazy(() => import("./MyComponent"), "MyComponent");
function App() {
return (
<div className="App">
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</div>
);
}
export default App;With this implementation, the custom retry mechanism handles errors during the import process and retries with a specified delay between each attempt. If all retries fail, it performs a full-page reload, but only once per module, thanks to the sessionStorage flag.
After the reload, if the module still fails through another full round of retries, the error is re-thrown for your error boundary to handle.
How the sessionStorage Flag Works
Without the reloadKey, a module that is permanently broken would reload the page forever. Here's the logic:
On first failure after all retries: set
reload_{moduleName} = "true"in sessionStorage and reload the page.After reload, if the same module fails again: the flag is present, so instead of reloading again, remove the flag and reject the promise (letting the error bubble to your
ErrorBoundary).On a successful load: the flag is cleared, so if the module fails again later in the same tab (after the next deployment, for example), the user still gets one reload.
This means the worst case for a user is three failed attempts, one page reload, three more failed attempts, and then your error boundary. With the defaults, that's about six seconds of retry delays plus the reload.
What Did the Retry Mechanism Change in Production?
After implementing the custom retry mechanism, we saw the number of app crashes drop by 92%. If you track a crash-free session metric in your monitoring tool, that's where a change like this shows up.
When Is a React.lazy() Retry Wrapper Worth Adding?
The wrapper is about 45 lines and has no runtime cost on the happy path, so the threshold for adding it is low.
It earns its place when three things are true: you use route-level or component-level code splitting, you deploy while users have sessions open, and your host serves hashed chunk filenames that change on every build. That describes most production single-page applications.
It is not a substitute for an error boundary. The retry handles the transient case, where the chunk can be fetched a moment later or after a reload. A missing chunk that stays missing still needs somewhere to land, and that is your error boundary's job.
What Should You Check Before Adding a React.lazy() Retry Wrapper?
Keep old chunks available during rollover
The retry buys seconds; it cannot fetch a file that no longer exists. If your deployment wipes the previous build's assets immediately, retain the last build's chunks for a window covering typical session length. This is the underlying fix; the wrapper handles what slips through. If you build with Vite, also serve your HTML with Cache-Control: no-cache, or browsers may keep referencing the old assets (Vite docs).
Expect the reload to do the work in native ES module builds
Some browsers, Chromium-based ones in particular, have historically remembered a failed dynamic import() of a URL for the life of the page. In a Vite build, which uses native import(), the in-page retries can then return the same error, and it's the reload that recovers. The HTML standard has since been changed so failed module fetches aren't cached (whatwg/html#10327), but browser support is still rolling out. Webpack's runtime loads chunks itself and requests a failed chunk again on retry.
Watch the sessionStorage key namespace
The flag is keyed per module name. Two modules sharing a name across different routes will share a reload flag, so use names unique within the app.
Server-side rendering needs a guard
Location.reload() and sessionStorage are browser APIs. The success path above is already guarded; if the import can also fail during SSR, guard the failure path with typeof window !== 'undefined' too.
Key Takeaways
The usual trigger is a deployment: chunk hashes change, and users on the old version request URLs that no longer exist.
That produces a
ChunkLoadError(Webpack) or a failed dynamic import (Vite), whichReact.lazy()throws to the nearest error boundary rather than retrying.retryLazy()makes up to three import attempts with a one-second delay between them (both configurable), then reloads the page once.The
sessionStorageflag prevents a reload loop; clearing it on success keeps the reload available for the next deployment.The wrapper works unchanged from React 16.6 through React 19.3. In the Next.js App Router, use it only in Client Components.
A Small Maintenance Surface Beats an Invisible Production Risk
The retry wrapper is a deliberate trade: about 45 lines your team owns, in exchange for removing a failure mode users can't work around on their own. That trade favors the wrapper in almost every production single-page application, because the costs aren't symmetric. A ChunkLoadError doesn't degrade gracefully; it ends the session for whoever hits it, right when a deployment is fresh enough that support tickets and rollback decisions are already in motion.
Reliability work like this rarely shows up as a headline feature. It shows up in the crash-free-session number staying flat across a release, in the on-call rotation not getting paged for a chunk hash mismatch, and in a support queue that doesn't spike every time engineering ships.
If post-deploy errors like this keep surfacing in your React app, Monterail's frontend team can help you review how it splits code, deploys and recovers. Get in touch.
React.lazy() Retry FAQ






