On This Page
Failed to fetch dynamically imported module after a deploy
This works on your laptop. It works on staging. It works in production, right after you ship. Then somebody who's had the tab open since lunch clicks "Reports", the screen goes white, and your error tracker fills up with the same line from a few hundred different browsers.
Failed to load resource: the server responded with a status of 404 (Not Found)
Failed to fetch dynamically imported module: http://localhost:4173/assets/Report-C1YCJdQY.jsIf that's your console, jump to the fixes. If you want to know why a retry button can't rescue you, keep reading.
What lands in the console
I captured these from a real repro (Chromium, React 19.3.0, Vite 8.3.4, details in the next section), so they're verbatim, apart from the Uncaught (in promise) prefix DevTools adds.
When the server answers 404 for the missing file:
Failed to load resource: the server responded with a status of 404 (Not Found)
Failed to fetch dynamically imported module: http://localhost:4173/assets/Report-C1YCJdQY.jsWhen the server has a single-page-app fallback and quietly returns index.html for any path it doesn't know (Netlify, nginx try_files, most static hosts do this by default), you get a different first line:
Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
Failed to fetch dynamically imported module: http://localhost:4173/assets/Report-C1YCJdQY.jsNote the network row for that one says 200. I'll come back to why that fooled me.
On Webpack and Next.js builds the same failure is named differently. A Sentry write-up on this error shows:
ChunkLoadError: Loading chunk _app-pages-browser_src_app_Home_tsx failed.I only ran Chromium. Firefox and Safari word the browser-level error differently from Chrome (a Mozilla forum thread title suggests Firefox uses the "Failed to fetch dynamically imported module" phrase in at least some versions, but I didn't test it). Match on a loose regex, not an exact string. More on that in the fix.
Two things this error is not. It isn't a React error, so there's no Minified React error #NNN in production, and nothing to look up in the decoder. It's a rejected promise from the browser's import(), which React's lazy() rethrows during render. And it isn't a hydration problem, even though it tends to show up around the same time as one after a bad deploy.
Reproduce it: build, load, redeploy, click
Scaffold and pin:
bash
npm create vite@latest repro -- --template react-ts
cd repro && npm install
npm ls react react-dom viteWhat I got on 12 October 2026:
+-- @vitejs/plugin-react@6.1.2
+-- react-dom@19.3.0
+-- react@19.3.0
`-- vite@8.3.4Two files. A chunk worth splitting:
tsx
// src/Report.tsx
export default function Report() {
return <p>Report chunk loaded</p>;
}And an app that loads it on click:
tsx
// src/App.tsx
import { lazy, Suspense, useState } from "react";
const Report = lazy(() => import("./Report"));
export default function App() {
const [open, setOpen] = useState(false);
return (
<main>
<button onClick={() => setOpen(true)}>Open report</button>
<Suspense fallback={<p>Loading…</p>}>{open && <Report />}</Suspense>
</main>
);
}Build it. Vite splits Report into its own hashed file:
bash
npm run build
ls dist/assetsReport-C1YCJdQY.js
index-DGNrK5qb.css
index-uNUfYF_B.jsNow serve dist with something that returns 404 for unknown paths (I used a short Node server; python3 -m http.server in dist works too). Open the page. Don't click anything yet. In another terminal:
bash
rm dist/assets/Report-*.jsThat's your "new deploy deleted the old files" moment. Click the button. White screen, because there's no error boundary, and React unmounts the whole root on an uncaught render error. I checked: document.body.innerText came back as an empty string, button and all.
Want the HTML-instead-of-JS variant? Serve with a catch-all that returns index.html and repeat. Same click, different console line.
The trigger is always a page that was loaded before the deploy asking for a chunk after it. Dev server won't show it. Preview won't, unless you do the rm.
Does your React version matter?
Barely. The failure is in the browser and the bundler, and React just passes it along.
| Release | What happens |
|---|---|
| 18.x | Same mechanism. lazy() rethrows the rejection, uncaught errors unmount the root. I didn't re-run my repro on 18. |
| 19.0 to 19.2 | Same. Root options onCaughtError / onUncaughtError exist, so you can report it centrally. Not re-run on these either. |
| 19.3.0 (September 9, 2026, per react.dev) | Everything above was run here. Error boundary catches it; onCaughtError fires with the TypeError. |
What does vary is the tooling. Vite documents a vite:preloadError event for this exact failure. I tested it on Vite 8.3.4; check your major's docs before relying on it on an older Vite. Webpack and Next.js give you ChunkLoadError and no equivalent event, so you handle it in a boundary.
If you're only here because of a framework upgrade: nothing in React 18 to 19 changes this. Your deploy process changed, or your host stopped keeping old assets, or somebody turned on a SPA fallback.
Why does the old tab ask for a file that's gone?
Because the HTML it loaded is a snapshot. It was built against index-uNUfYF_B.js, and inside that file is a map saying "when someone clicks Reports, import Report-C1YCJdQY.js". Bundlers put a content hash in every filename so browsers can cache forever. Change one byte in Report.tsx, and the next build calls it Report-cBgWyfQj.js instead.
If your deploy replaces the assets folder (most do), the old name stops existing at the moment the new build goes live. The old tab doesn't know. It asks for the old name. The server says 404, or hands back index.html with a 200 and the wrong content type.
The ticket that started this for me said "Reports page is blank, fixed itself after a refresh". Support closed three of those as user error before anyone opened the console.
That's the whole mechanism. The rest of this article is about the three things that make it worse than it sounds.
One layer down: what lazy() does with a rejection
Three separate caches are lined up against you, and I hit all three before I understood any of them.
lazy() remembers failure. Each lazy() object holds a small payload with a status: not started, pending, resolved or rejected. React reads that status on every render. Once the status is rejected, React throws the stored error again without calling your loader. The react.dev reference says the same thing in gentler words: both the promise and its resolved value are cached, so load isn't called more than once.
I confirmed it with a counter in the loader. One click, then a "Retry" button in my error boundary that cleared the boundary's state. The counter never moved past 1. The boundary re-rendered, React looked at the rejected payload, threw, and the boundary caught it again. Retrying a failed lazy() by re-rendering is a no-op, always.
The browser remembers failure too. This is the one I'd never have guessed. That matches how the HTML spec's module map is meant to work: a module URL that failed to fetch stays recorded as failed for that document, and a second import() of the same URL doesn't go back to the network. I only measured Chromium. In Chromium I did this:
1st (file missing): TypeError: Failed to fetch dynamically imported module: .../Report-C1YCJdQY.js
2nd same URL (file restored): TypeError: Failed to fetch dynamically imported module: .../Report-C1YCJdQY.js
3rd with ?t=1 appended: okThe file was back on disk for the second try. Still failed. A different query string worked immediately. So for this document, in this browser, that URL is poisoned until you reload.
The root unmounts. With no boundary above the Suspense, the error is uncaught, and the whole tree goes. In React 19 you can see it through the onUncaughtError root option.
Put those together and you can see why the "just retry three times with a delay" snippets floating around don't survive contact with a deploy. They solve a different problem, which is a flaky network blip on a file that still exists. A deleted file stays deleted.
The wrong turns, then the path that worked
This ate most of a day. The tab-open-since-lunch detail is what made it slow, because it doesn't reproduce by clicking around a fresh page.
Dead ends
"It's a network flake." First guess, and the usual one. I looked at the Network tab, saw 200 on the chunk request, and ruled the server out. Wrong. The status was 200 because the host had an SPA fallback, and the Content-Type column said text/html. I'd been staring at the Status column when the answer was one column over. Add Content-Type to that table if it isn't there already.
"I'll just add a Retry button." Wired a boundary with a Retry that cleared its error. Did nothing, for the lazy() reason above. The loader counter staying at 1 was the evidence.
"Okay, make a new lazy() on retry." Put the component in state, replaced it with a fresh lazy(() => import("./Report")) on click. The counter went to 2. The error didn't change. This is the browser's module map doing its job; it never even asked the server. I only worked it out by calling import() by hand in the console and seeing the second call fail while a ?t=1 call succeeded.
The path that worked on a different app
Run these in order.
- Open the Network tab, filter to JS, click the failing request. Look at Status and Content-Type.
404: the file's gone.200withtext/html: file's gone, host is covering for it.(blocked)or a CORS error: not a deploy problem, jump to cause 5 below. - Take the hash from the failing URL and ask the live site whether it exists.
bash
curl -sI https://your.site/assets/Report-C1YCJdQY.js | grep -iE "^(HTTP|content-type)"404 or text/html confirms it. 3. Compare against what the live HTML actually references.
bash
curl -s https://your.site/ | grep -o 'assets/[^"]*\.js'If the hash in the error isn't in the live entry chunk's import map, it's a stale tab. Done. 4. Check timing. In your error tracker, group by release. A burst of these starting within minutes of a deploy, then tailing off over hours, is the signature. A steady trickle with no deploy correlation is something else (cause 4 or 6). 5. Application tab, Service Workers. If one is registered, it may be serving an old index.html forever. That makes the problem permanent instead of temporary. 6. Check base and your CDN path only if the failing URL itself looks wrong, like /assets/ where it should be /app/assets/.
The tell, for me: the chunk hash in the failing URL didn't appear anywhere in the HTML the server was returning right now. That one observation separates "stale tab" from everything else.
Causes, ranked by how often they're the one
- Stale tab after a deploy deleted old chunks. By a wide margin. This is what bit me.
- Same thing with an SPA fallback hiding the 404. Same cause, different disguise, and it makes #1 harder to diagnose.
- Wrong
baseor asset path after a move to a CDN or a sub-path. Fails for everyone, immediately, not just old tabs. - Real network failure. Flaky mobile connection, a corporate proxy, an extension blocking scripts. Scattered, no deploy pattern.
- Cross-origin chunks without CORS headers. Module scripts are fetched in CORS mode, so a CDN that works for classic
<script>tags can still refuse yourimport(). - A service worker serving an old shell. Rare, but it never heals on its own.
Fixing it, best option first
There are really two jobs. Keep the user's session alive when it happens, and make it happen less.
On the client: reload, once
Vite fires vite:preloadError when a dynamic import fails. The docs show reloading the page in that handler and say to cancel the throw with preventDefault(). Reloading works here because a fresh load fetches new HTML, which points at hashes that exist. I'd add a loop guard, because a reload that fails again would otherwise loop forever.
tsx
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
window.addEventListener("vite:preloadError", (event) => {
event.preventDefault();
const key = "preload-reload-at";
const last = Number(sessionStorage.getItem(key) ?? 0);
if (Date.now() - last > 10_000) {
sessionStorage.setItem(key, String(Date.now()));
window.location.reload();
}
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);I tested this: page navigated once, came back showing the button, no loop. One caveat from my test. I only deleted the chunk and didn't ship new HTML, so the reloaded page would fail again on the next click. The guard held. In a real deploy the reload picks up the new HTML and the click works.
I don't love this fix. It throws away whatever state the user had: half a form, a scroll position. But the alternative is a blank page, and I haven't found another option that works against that module-map cache.
Not on Vite: a boundary that reloads
Webpack and Next.js don't have the event. Catch it in an error boundary and offer a button that really reloads, not one that re-renders:
tsx
import { Component, type ReactNode } from "react";
const CHUNK_ERROR = /dynamically imported module|Loading chunk|ChunkLoadError|Importing a module script failed/i;
export class ChunkBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
if (CHUNK_ERROR.test(`${error.name} ${error.message}`)) {
return <button onClick={() => location.reload()}>A new version is out. Reload</button>;
}
throw error;
}
}The throw error line passes anything unrelated up to the next boundary. The regex includes "Importing a module script failed", a message I believe Safari uses but couldn't verify here, so treat that one as a guess.
On the server: stop making it worse
- Don't delete the previous build's hashed files the moment you deploy. Keep the last few releases around, even for a day. Upload new assets first, flip the HTML second.
Cache-Control: no-cacheonindex.html. Vite's docs call this out directly: without it, browsers keep serving HTML that points at old assets.- Return a real 404 for missing files under
/assets/, notindex.html. It makes the error obvious in logs, and CDNs won't cache an HTML body under a.jsURL.
If you're on a host that offers pinning each client to the deployment it loaded from (Vercel calls it Skew Protection), that fixes the root cause. I haven't tested it.
Fixes that only look like fixes
- A retry loop around
import(). Fine for flaky networks. Useless for deleted files, and as the module-map result shows, useless for same-URL retries in Chromium even after the file reappears. ?t=cache busting on the import. It worked in my console experiment, but your bundler generates the specifiers, so you'd be fighting the build output. Skip it.- Hiding the error with an empty
catch. You'll get a permanent spinner instead of a blank page. Worse.
The version I'd ship
The reload-on-error fix is a safety net. Better to tell the user before they hit it.
A tiny build step writes version.json next to the app, and a hook polls it:
ts
// vite.config.ts
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
const buildId = process.env.GIT_SHA ?? Date.now().toString(36);
function versionFile(): Plugin {
return {
name: "version-file",
generateBundle() {
this.emitFile({ type: "asset", fileName: "version.json", source: JSON.stringify({ buildId }) });
},
};
}
export default defineConfig({
plugins: [react(), versionFile()],
define: { __BUILD_ID__: JSON.stringify(buildId) },
});ts
// src/useNewVersion.ts
import { useEffect, useState } from "react";
declare const __BUILD_ID__: string;
export function useNewVersion(everyMs = 5 * 60_000) {
const [stale, setStale] = useState(false);
useEffect(() => {
const ctrl = new AbortController();
const check = async () => {
try {
const res = await fetch("/version.json", { cache: "no-store", signal: ctrl.signal });
if (!res.ok) return;
const { buildId } = (await res.json()) as { buildId: string };
if (buildId !== __BUILD_ID__) setStale(true);
} catch {
/* offline or aborted: say nothing */
}
};
const id = setInterval(check, everyMs);
document.addEventListener("visibilitychange", check);
return () => {
ctrl.abort();
clearInterval(id);
document.removeEventListener("visibilitychange", check);
};
}, [everyMs]);
return stale;
}Render {stale && <button onClick={() => location.reload()}>New version available, reload</button>} anywhere near the top of App. This built clean with tsc -b && vite build and emitted {"buildId":"mv4ngpwm"} into dist/version.json. The effect cleans up its timer, its listener and its in-flight fetch, so StrictMode's mount-unmount-mount in dev doesn't leave a second poller behind.
So the stack I'd actually ship: the poll banner so most people reload on their own terms, the vite:preloadError handler (or ChunkBoundary) for the ones who click first, and the server rules so a stale tab gets an honest 404.
Alerts that tell you before the users do
React's lint rules won't catch this. There's no rules-of-hooks angle; the code is correct, the deploy is the bug. What catches it is the pipeline.
- A deploy test that reproduces my
rmstep. Playwright: load the page, delete the chunk, click, assert the app recovers (reloads, or shows the banner) and doesn't go blank. My repro script is about twenty lines. - Fail E2E runs on unexpected console errors, so a 404 on a hashed asset is red in CI, not just in a user's browser.
- Error tracker rule: alert on the regex above, grouped by release, and watch the ratio to active sessions after each deploy. A short spike is normal. One that doesn't decay means the server rules aren't in place, or a service worker is pinning old HTML.
- A CI check that the deploy script doesn't wipe the previous release's assets. One line in the pipeline saves you the whole class.
Related, and a neighbour worth linking to: lazy() rejection caching ties into Suspense and error boundaries, and the onCaughtError / onUncaughtError root options are the place to report these centrally.
If you remember nothing else
- Hashed chunks are deleted by the next deploy. Every open tab is running a snapshot that can go stale without anyone noticing.
- A rejected
lazy()stays rejected, and in Chromium a failed module URL stays failed for the document. Retrying is theatre; only a reload clears both. 200doesn't mean the file exists. Read Content-Type.- Fix it in three places: client reload as the net, a version banner as the warning, a server that keeps old assets and returns honest 404s.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.