Checked against official sources on October 26, 2026
This bug hides in production. It never shows up on your laptop, and it only hits some people. We rebuilt it on our own machine, fixed it, and ran every piece of code below before writing a word about it.
What happened
A developer on a React forum laid it out clearly. A user opens the app and leaves the tab open. The team ships a new version. Later, the user clicks a link to a page that loads with React.lazy. The page breaks.
The error says "Failed to fetch dynamically imported module" or ChunkLoadError. In a fresh tab, the same page works fine. An error boundary can show a friendly message, but it cannot fix the tab. Trying the same import again fails again.
Why it breaks
Build tools like Vite give each built file a name with a hash in it, such as Reports-D6ha3SoT.js. The hash changes when the code changes. That is great for caching. A browser can keep a hashed file for a long time, because new code always gets a new name. web.dev calls these "versioned URLs."
Now think about the old tab. It still runs the old main file in memory. That old file only knows the old name, Reports-D6ha3SoT.js. After the deploy, the server has Reports-CEShBa1e.js instead. If the old file was deleted, the server answers 404, and the import fails.
The Vite docs describe this exact case. When a host deletes the files from earlier deploys, a person who visited before the deploy "might encounter an import error."
Two more facts make it worse:
-
import()returns a promise. If the file is missing, that promise rejects. - React caches the promise behind
lazy. The React docs say React will not call the load function more than once. If the promise rejects, React throws the reason to the nearest error boundary. So the component stays broken until the page loads fresh code.
That is why a retry does not help. The old tab just asks for the same old name again.
Our fix: three layers
No single trick covers every case. We use three layers, from the server out to the browser.
Layer 1: the right cache headers
- Send
Cache-Control: no-cacheonindex.html. MDN explains thatno-cachelets a cache store the file, but it must check with the server before each reuse. So a fresh page load always finds the newest entry file. The Vite docs ask for this same header on the HTML file. - Send a long cache with
immutableon hashed files in/assets/. Their names change when their content changes, so this is safe.
Here is the tiny Node server we used to test these headers:
// Tiny static server with the cache headers we recommend.
import http from 'node:http'
import { readFile } from 'node:fs/promises'
import { extname, join, normalize } from 'node:path'
const root = process.argv[2], port = Number(process.argv[3] || 4173)
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }
http.createServer(async (req, res) => {
let path = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname)).replace(/^(\.\.[/\\])+/, '')
if (path === '/') path = '/index.html'
try {
const body = await readFile(join(root, path))
const cache = path.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'
res.writeHead(200, { 'Content-Type': types[extname(path)] || 'application/octet-stream', 'Cache-Control': cache })
res.end(body)
} catch { res.writeHead(404, { 'Cache-Control': 'no-store' }); res.end('Not found') }
}).listen(port, () => console.log('serving', root, 'on', port))
Layer 2: deploy in the right order, and keep old files for a while
- Upload the new files in
/assets/first. - Upload
index.htmllast, so it never points at a file that is not there yet. - Do not delete the old hashed files right away. Keep them for a set time, then clean them up with a script.
With the old files still on the server, the old tab loads its old chunk, and nothing breaks. How long you keep them is your call. Think about how long your users leave tabs open.
Layer 3: one safe reload in the browser
Sometimes you must delete old files fast, for example after a security fix. Then the old tab needs fresh code, and the simple way to get it is a reload. The risk is a reload loop: if the file is still missing after the reload, the page would reload forever.
So we keep a flag in sessionStorage. MDN says sessionStorage belongs to one tab and survives reloads. That is just what we need: reload once per tab, then stop and show the error.
We kept the logic apart from React so it is easy to test.
src/loadWithOneReload.js:
// Pure helper: no React here, so it is easy to test.
const CHUNK_ERROR = /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|Loading chunk [\w-]+ failed/i
export function isChunkError(error) {
return Boolean(error) && CHUNK_ERROR.test(String(error.message || error))
}
export function loadWithOneReload(importer, {
key,
storage = window.sessionStorage,
reload = () => window.location.reload(),
}) {
const flag = `chunk-reload:${key}`
return importer().then(
(module) => {
storage.removeItem(flag) // it worked, so clear the flag
return module
},
(error) => {
if (!isChunkError(error)) throw error // a real bug: show it
if (storage.getItem(flag)) throw error // we already reloaded once: stop
storage.setItem(flag, String(Date.now()))
reload()
return new Promise(() => {}) // wait here while the page reloads
},
)
}
src/lazyWithReload.js:
import { lazy } from 'react'
import { loadWithOneReload } from './loadWithOneReload.js'
export function lazyWithReload(importer, key) {
return lazy(() => loadWithOneReload(importer, { key }))
}
Using it looks like normal lazy. Keep an error boundary around it for the case where the reload did not help:
const Reports = lazyWithReload(() => import('./Reports.jsx'), 'reports')
Why we made these choices:
- We only reload for chunk errors. A normal bug, like a typo in the module, is thrown as usual. A reload would only hide it.
- The flag is cleared after a good load. So if you deploy again tomorrow, the same tab can recover again.
- Browsers word this error in different ways. The pattern covers the common messages we know of, but it is a best effort, so keep it current.
If you use Vite, there is also a built-in hook. The Vite docs say Vite fires a vite:preloadError event when it fails to load a dynamic import, and calling event.preventDefault() stops the error from being thrown. Their example simply reloads the page. Our helper works without it, but you could use that event the same way, with the same one-reload flag.
How we tested it
Our setup: Node 20.19.2, Vite 8.3.5, React 19.3.1, the built-in node:test runner, and Google Chrome (headless) driven by Playwright.
1. Unit tests for the helper. We passed in a fake storage and a fake reload, so the tests run in plain Node. Five tests:
- The first chunk error calls reload once, and the promise keeps waiting.
- A second chunk error, after the reload, throws. No loop.
- A good load clears the flag.
- A normal error (
ReferenceError) is thrown, with no reload. - The pattern matches the common chunk error messages and skips a normal error.
$ node --test test/
# tests 5
# pass 5
# fail 0
2. A two-deploy test over HTTP. We built the app twice, as build A and build B, with one change in the lazy page. Then we served build B two ways with the server above, and asked for build A's lazy file, like an old tab would:
$ bash deploy-test.sh
build A lazy chunk: Reports-D6ha3SoT.js
build B lazy chunk: Reports-CEShBa1e.js
build A entry file points at Reports-D6ha3SoT.js
clean deploy, old tab asks for Reports-D6ha3SoT.js: HTTP 404
keep-old deploy, old tab asks for Reports-D6ha3SoT.js: HTTP 200
new index.html points at: assets/index-C0CsCx8r.js
index.html header: Cache-Control: no-cache
asset header: Cache-Control: public, max-age=31536000, immutable
The clean deploy shows the bug: the old name is gone. The keep-old deploy fixes it.
3. A real browser. We opened the app on build A in Chrome. With the tab still open, we deployed again. Then we clicked "Open reports." We counted page loads and read what the page showed. We ran three cases:
- Keep-old deploy: build A and build B files are both on the server.
- Clean deploy: only build B files are on the server.
- Chunk still gone: build B, but with the lazy file removed too, so even a reload cannot help.
$ python3 browser-test.py
keep-old deploy : page loads=1 (reloads=0), shows: 'Reports page, build A'
clean deploy : page loads=2 (reloads=1), shows: 'Reports page, build B'
chunk still gone: page loads=2 (reloads=1), shows: 'This page could not load. Reload'
This is what we wanted. With old files kept, the user notices nothing. With a clean deploy, the tab reloads once and gets the new page. When the file is gone for good, the tab reloads once, then shows the error boundary. No loop.
One honest detail: in our small demo, the lazy page opens from a button, not a URL. So after the reload, we had to click again. In a real app with a router, the URL already points at the lazy page, so the reload lands right on it.
Trade-offs
- Keeping old files costs a little storage. It is also the smoothest fix, because users notice nothing.
- Old files stay public while you keep them. Anyone can still fetch old code at its old URL, including code you meant to remove. Keep them for a set time, not forever. If a secret ever got into a bundle, change the secret. Deleting the file is not enough.
- A reload can lose work. If the user typed into a form, a reload wipes it. This is why Layer 2 is our first defense, and the reload is the backup.
- Matching error text is fragile. Browser messages can change. Keep the pattern in one place, and test it.
- Another option is a version check. The app asks the server for the current version and asks the user to reload when it is out of date. It is more work, but the user picks the moment to reload.
How to check the fix on your own app
- Run
curl -Ion yourindex.htmland on one file in/assets/. You should seeno-cacheon the first and a longmax-ageon the second. - Open your app in a tab. Deploy a change to a lazy page. Do not refresh.
- Click the link to that lazy page. With old files kept, it should just work. With old files deleted, you should see one reload, and then the new page.
- In the browser's dev tools, the Network panel should show the failed chunk request (a 404), then a fresh
index.html. - Delete the file on purpose and try again in the same tab. After one reload, you should see your error boundary, not an endless reload.
Docs we read
- Vite docs, "Building for Production", the "Load Error Handling" part (on vite.dev)
- React,
lazy: https://react.dev/reference/react/lazy - React,
Component(error boundaries): https://react.dev/reference/react/Component - MDN,
import(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import - MDN,
Cache-Control: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control - MDN,
Window.sessionStorage: https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage - MDN,
Location.reload(): https://developer.mozilla.org/en-US/docs/Web/API/Location/reload - web.dev, "Prevent unnecessary network requests with the HTTP Cache": https://web.dev/articles/http-cache
From TalentOp Systems LLC
We are TalentOp Systems LLC, based in Omaha, Nebraska. We started in February 2025. We work in IT staffing, IT software consulting, project development, and manpower and staffing solutions for clients across the United States. Website: www.talentopsystems.com. Email: info@talentopsystems.com.
Top comments (0)