Retrying Failed Dynamic Imports With Exponential Backoff
The error arrives in monitoring as a flat, unhelpful line:
Uncaught (in promise) TypeError: Failed to fetch dynamically imported module:
https://cdn.example.com/assets/reports-Bq7x2f.js
at ReportsRoute (router.js:118)
No status code, no timing, no indication whether the file was missing, the connection dropped, or an intermediate cache returned something malformed. What is knowable is that a user tried to reach a feature and did not get there — and that on a mobile network, a meaningful share of those failures would have succeeded on a second attempt a few hundred milliseconds later.
A retry helper is the cheapest resilience you can add to a split application. This page is the production version: the detection logic, the backoff schedule, the attempt ceiling, and the two details — jitter and last-attempt cache busting — that separate a helper that survives an incident from one that amplifies it.
Root cause: a single request, no second chance
The classification framework in handling lazy chunk load failures and fallbacks splits chunk failures into transient network errors, stale manifests, and evaluation errors. Retrying only helps the first category — but that category is the largest, because the failure modes behind it are ordinary and momentary: a radio handover during a subway transit, a packet lost on a saturated uplink, a captive portal that intercepts one request, an edge node cycling out of rotation.
The bundler runtime does not retry on your behalf. Webpack’s chunk loader rejects on the script element’s error event; Vite’s rejects on the dynamic import’s own failure. In both cases exactly one request is made, and its failure is final unless application code intervenes.
The helper
// retry-import.js — production retry for dynamic import()
// Works with Webpack 5 and Vite 5+; both reject the returned promise on failure.
const FETCH_FAILURE = /ChunkLoadError|Loading chunk|Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i;
const BASE_DELAY = 300; // ms before the first retry
const MAX_ATTEMPTS = 3; // one initial attempt + two retries
<svg viewBox="22 36 676 146" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two properties of the retry helper: it takes a factory rather than a promise, and counts upward against a fixed ceiling" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;">
<title>Two Details That Decide Correctness</title>
<desc>The helper must receive a factory so a fresh request can be made, and must count attempts upward so reported attempt indexes stay accurate.</desc>
<rect x="22" y="36" width="676" height="146" fill="#FAF7F0"/>
<rect x="30" y="44" width="300" height="88" rx="6" fill="none" stroke="#e8552a" stroke-width="1.5"/>
<text x="180" y="68" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11" font-weight="600">retry(promise)</text>
<text x="180" y="90" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">a rejected promise stays rejected</text>
<text x="180" y="110" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">no second request is ever made</text>
<rect x="390" y="44" width="300" height="88" rx="6" fill="none" stroke="#2a9e5e" stroke-width="1.5"/>
<text x="540" y="68" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11" font-weight="600">retry(factory)</text>
<text x="540" y="90" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">calling it again issues a fresh request</text>
<text x="540" y="110" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">the bundler drops a failed chunk record</text>
<text x="360" y="170" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11">The same distinction applies to every retry wrapper, not only chunk loading</text>
</svg>
export function retryImport(factory, name) {
const attempt = (n) =>
factory().catch((error) => {
const message = String((error && error.message) || '');
// An evaluation error inside the chunk: retrying re-runs the same bug.
if (!FETCH_FAILURE.test(message)) throw error;
report('chunk_attempt_failed', { name, attempt: n });
if (n >= MAX_ATTEMPTS) throw error;
// Exponential backoff with jitter: correlated failures across many tabs
// must not resynchronise into a burst against an already-unhealthy origin.
const delay = BASE_DELAY * Math.pow(2, n - 1) + Math.random() * 250;
return new Promise((resolve) => setTimeout(resolve, delay)).then(() => attempt(n + 1));
});
return attempt(1);
}Two properties of that code are easy to get wrong. It takes a factory, not a promise: a rejected promise stays rejected forever, so retrying requires calling import() again. And the recursion counts upward against a constant ceiling rather than decrementing a mutable counter, which keeps the attempt index correct in the reported events.
Wiring it into a lazy component is a one-line change at each call site:
// routes.js — React 18+
import { lazy } from 'react';
import { retryImport } from './retry-import';
export const ReportsRoute = lazy(() =>
retryImport(() => import('./routes/Reports'), 'reports')
);Busting a poisoned cache on the last attempt
A small but persistent share of chunk failures come from an intermediary — a corporate proxy, an ISP transparent cache, a misconfigured edge — holding a truncated or corrupted copy of the file. Those fail identically on every retry, because every retry hits the same cache entry.
The fix is to make the final attempt look like a different URL. Both bundlers accept a public path override at runtime, which is the cleanest injection point:
// cache-bust.js — force the LAST attempt past any intermediate cache entry
export function bustOnFinalAttempt(n, maxAttempts) {
if (n < maxAttempts) return; // keep legitimate cache hits on earlier tries
// Webpack 5: the runtime reads this global when constructing chunk URLs.
if (typeof __webpack_get_script_filename__ === 'function') {
const original = __webpack_get_script_filename__;
__webpack_get_script_filename__ = (chunkId) =>
`${original(chunkId)}?cb=${Date.now()}`;
}
}Applying this on every attempt would be actively harmful: it discards valid cached copies and multiplies origin load during the exact incident the retry is meant to survive. Reserve it for the last attempt, where the alternative is a guaranteed failure anyway.
Step-by-step verification
-
Block one request. With DevTools request blocking, block the chunk URL, trigger the import, then unblock before the second attempt. Confirm the retry succeeds and the component renders.
-
Confirm the attempt ceiling. Leave the block in place. Exactly three requests should appear, then the error boundary should render — not an indefinite request loop.
-
Confirm evaluation errors are not retried. Add a top-level throw to the imported module. There must be exactly one request and one reported event; a retry here is a bug.
-
Check the backoff spacing. Read the request timestamps in the Network panel: roughly 300 ms and 600 ms apart, each with a variable offset. Identical spacing across runs means the jitter is not applied.
-
Confirm the last attempt is cache-busted. The third request URL must carry the extra query parameter; the first two must not.
-
Confirm reporting. Each attempt should emit its own event with the chunk name and attempt index, so retry volume is chartable alongside the field metrics described in measuring real-user chunk loading performance.
Edge cases and gotchas
Retrying a stale manifest. If a deploy has removed the file, all three attempts return 404 and cost the user roughly a second before the honest failure. Gate the retry behind the build-version check described in fixing ChunkLoadError after a new deploy so a stale page escalates to a reload instead.
Preloaded chunks that fail. A chunk fetched by a <link rel="prefetch"> hint fails silently, outside your retry helper entirely — the subsequent import() then issues its own request and enters the retry path normally. Do not attempt to retry prefetch failures; they are advisory by design.
Service worker interception. A service worker that caches chunk responses can serve the same broken response to every attempt, including the cache-busted one if its cache key ignores query parameters. Confirm the worker’s matching strategy before concluding the bust is ineffective.
Retry storms from a shared module. If twenty components all import the same failing chunk, twenty independent retry chains start. Deduplicate at the loader level by caching the in-flight promise per chunk name, so one retry chain serves every caller.
FAQ
Why add jitter to the backoff delay?
Because failures are correlated. When an edge node returns errors, every tab that requested a chunk in that window fails at approximately the same moment, and a fixed backoff schedules all of their retries for the same moment too. The result is a synchronised burst of requests hitting an origin that is already unhealthy. A random offset of up to a few hundred milliseconds spreads those retries across a window and turns a spike into a trickle.
Does retrying an import() actually re-issue the network request?
Yes, when the first attempt failed. Bundler runtimes cache the promise for a chunk while it is in flight and after it resolves, but a rejected chunk load is removed from the registry, so calling the import factory again produces a fresh request. What does not work is retrying a promise you already hold — you must call the factory function again, which is why retry helpers take a factory rather than a promise.
Should the retried request bypass the HTTP cache?
On the last attempt, yes. A small share of chunk failures are caused by a corrupted or truncated response cached by an intermediary, and those fail identically on every retry that hits the same cache entry. Appending a unique query parameter to the final attempt forces a fresh fetch past that entry. Doing it on every attempt is counterproductive: it discards legitimate cache hits and puts avoidable load on the origin during exactly the incident you are trying to survive.
Related
- Handling Lazy Chunk Load Failures and Fallbacks — the parent guide covering classification, boundaries, and degraded states
- Designing Suspense Fallbacks That Avoid Layout Shift — what the user sees during the retry window
- Fixing ChunkLoadError After a New Deploy — the failure class retries cannot fix
- Measuring Real-User Chunk Loading Performance — turning retry events into a field signal