# Fix: ChunkLoadError: Loading chunk failed / Failed to fetch dynamically imported module

The page tried to load a piece of your JavaScript bundle and the file was not there. Almost always this happens right after a deploy: the user's open tab still runs the old build, and it asks for an old chunk that the new deploy removed. The fix is to keep old assets available for a while, stop caching the HTML, and reload the page once when a chunk fails to load.

| Fact | Detail |
|---|---|
| Error (webpack, and Next.js on webpack) | `ChunkLoadError: Loading chunk 123 failed.` followed by `(error: https://example.com/_next/static/chunks/123.js)` |
| Next.js on Turbopack | `ChunkLoadError: Failed to load chunk /_next/static/chunks/….js from module …` |
| Vite, Chrome | `TypeError: Failed to fetch dynamically imported module: https://example.com/assets/Settings-4f2a.js` |
| Vite, Firefox | `TypeError: error loading dynamically imported module: …` |
| Vite, Safari | `TypeError: Importing a module script failed.` |
| Where you see it | Browser console, error reports from real users shortly after a deploy |
| Usual cause | A tab opened before the deploy requests a chunk the deploy deleted |
| Usual fix | Keep old chunks, send `Cache-Control: no-cache` on HTML, reload once on failure |
| BugTape catches it | Yes, automatically, when the error is uncaught or reaches React's default handling. Errors your own boundary catches need one line |

## What the error means

Bundlers split your app into chunks. The first page load gets the entry chunk, and other chunks are loaded later with `import()` when the user opens a route or a component. Each chunk has a content hash in its file name, such as `Settings-4f2a.js`, so a new build produces new names.

When you deploy, the HTML and the entry chunk now point to the new names. A tab that was opened before the deploy still runs the old entry chunk, so it asks for the old names. If the host no longer serves them, the request returns 404 (often with an HTML page), and the import fails.

webpack reports this as `ChunkLoadError` with `Loading chunk <id> failed.` and the type of failure in brackets: `error` when the script failed to load, `timeout` when it took too long, or `missing` when the file loaded but did not contain the chunk. Turbopack, which Next.js 16 uses by default, names the error `ChunkLoadError` too, with the message `Failed to load chunk <url> from module <id>`, and retries a chunk that failed with a network error before it gives up. Vite uses the browser's native `import()`, so the message is the browser's own and differs between Chrome, Firefox and Safari.

## Common causes

1. **A deploy removed the old chunks.** The most common case by far. The errors cluster in the minutes and hours after each release.
2. **The HTML is cached.** A CDN or browser serves an old `index.html` that points to chunks from an earlier build.
3. **The chunk URL is wrong.** A wrong `base` (Vite) or `publicPath`/`assetPrefix` (webpack, Next.js) after moving the app to a subpath or a CDN.
4. **A flaky network.** A mobile connection drops while the chunk downloads, or a timeout fires.
5. **Something blocks the file.** An ad blocker matches the chunk name, a Content Security Policy blocks the script origin, or a corporate proxy rewrites the response.
6. **A single-page-app fallback returns `index.html` for the missing chunk.** The browser gets HTML where it expected JavaScript, which can also show up as a syntax error.

## How to fix it

### Keep old assets available after a deploy

Old tabs keep working if the old chunks are still served. On static hosts that upload a new folder per deploy, keep the previous build's `assets/` (or `_next/static/`) files for a few days instead of deleting them. Hashed file names never collide, so old and new files can live side by side.

On Vercel, Skew Protection keeps framework requests from an old tab pinned to the deployment that served the page. It is available on Pro and Enterprise plans and works with Next.js, SvelteKit, Nuxt, Astro and Qwik.

### Do not cache the HTML

The HTML must always be fresh, so new visits get the new chunk names. The hashed assets can be cached forever.

```
# index.html and other HTML
Cache-Control: no-cache

# /assets/* (hashed file names)
Cache-Control: public, max-age=31536000, immutable
```

### Reload once when a Vite chunk fails

Vite fires a `vite:preloadError` event on `window` when a dynamic import or its preloads fail. Reload the page so the user gets the new build. Guard it so a chunk that is really missing does not reload forever.

```ts
// main.ts, before the app mounts
window.addEventListener('vite:preloadError', (event) => {
  const key = 'chunk-reload-at';
  const last = Number(sessionStorage.getItem(key) ?? 0);
  if (Date.now() - last < 10_000) return; // reloaded just now: let the error through
  sessionStorage.setItem(key, String(Date.now()));
  event.preventDefault();
  window.location.reload();
});
```

If you call `event.preventDefault()`, Vite does not throw the error. Without it, the error is thrown as usual.

### Reload once when a webpack chunk fails

For webpack, React `lazy()` or Next.js, catch the error in an error boundary and reload once. `ChunkLoadError` has `name === 'ChunkLoadError'`. The browser messages from native imports can be matched by text.

```tsx
'use client';
import { useEffect } from 'react';

function isChunkError(error: Error) {
  return (
    error.name === 'ChunkLoadError' ||
    /Loading chunk [\w-]+ failed|Failed to load chunk|Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/.test(error.message)
  );
}

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  useEffect(() => {
    const key = 'chunk-reload-at';
    const last = Number(sessionStorage.getItem(key) ?? 0);
    if (isChunkError(error) && Date.now() - last > 10_000) {
      sessionStorage.setItem(key, String(Date.now()));
      window.location.reload();
      return;
    }
    reportError(error); // see the BugTape section below
  }, [error]);

  return <button onClick={() => reset()}>Try again</button>;
}
```

### Retry the import for flaky networks

For one important lazy component, retry the import before giving up. Browsers that support retrying failed module loads can succeed on a second `import()`. Older browsers cache the failure, and only a reload helps.

```ts
import { lazy } from 'react';

function lazyWithRetry<T extends React.ComponentType<any>>(load: () => Promise<{ default: T }>) {
  return lazy(async () => {
    try {
      return await load();
    } catch {
      await new Promise((r) => setTimeout(r, 1000));
      return load();
    }
  });
}

const Settings = lazyWithRetry(() => import('./Settings'));
```

### Check the asset base URL

If every user fails, and not only after deploys, the URL is wrong. Open the failing chunk URL from the error message in a new tab. In Vite, set `base` to the path the app is served from. In Next.js, `assetPrefix` must match where `_next/static` is served.

```ts
// vite.config.ts
export default defineConfig({ base: '/app/' });
```

## How to catch it in production with BugTape

How the error reaches the BugTape browser SDK depends on where it is thrown:

- **An `import()` in an event handler or effect without a `catch`** fires `unhandledrejection`. BugTape reports **Unhandled promise rejection: Loading chunk 123 failed.** or **…Failed to fetch dynamically imported module…** with the stack, the page URL and the clicks before it.
- **A `lazy()` component during render that no boundary of yours catches** is passed by React 19 to `reportError()`, which fires a window `error` event. In the Next.js App Router, errors caught by the built-in default boundary are reported the same way. BugTape reports it as a JavaScript error.
- **An error caught by your own `error.tsx` or error boundary** is only logged with `console.error`. BugTape records console output into a report, but console output does not start one. Call `reportError(error)` in the boundary, as in the example above.
- **If your `vite:preloadError` handler calls `preventDefault()` and reloads**, nothing is thrown and nothing is reported. With the once-only guard above, a chunk that fails again after the reload is thrown and reported, which is the case you need to see.

Chunks are loaded with `<script>` tags or native `import()`, so they are not `fetch` or XHR requests, and BugTape does not report them as failed requests. The SDK also records nothing before `init()` runs.

Repeats with the same message on the same page become one issue with a count. Chunk ids and file hashes change with each build, so a new deploy can start a new issue. Set `release` in `init()` so each issue shows the release it came from. A burst right after a deploy, from users on the previous release, points to skew between old tabs and new assets. When you mark the issue fixed, a report from a newer release reopens it as a regression.

```ts
import { init } from '@bugtape/sdk';

init({ apiKey: 'YOUR_PROJECT_KEY', release: import.meta.env.VITE_COMMIT_SHA });
```

Your coding agent can read the issue over MCP (`npx -y bugtape connect claude`, or `cursor` or `codex`), see the chunk URL and the release, and propose the change to caching or reload handling. You review the pull request.

## FAQ

### Why does this happen right after every deploy?

Users who had the app open before the deploy still run the old code. When they open a new route, the old code asks for an old chunk. If your host removed the old files during the deploy, that request fails. Keeping old assets for a while, or pinning old tabs to their deployment, removes most of these errors.

### Is reloading the page a good fix?

A single reload is a good fallback. It loads the new HTML and the new chunk names, and the user usually lands on the same URL. Guard it so it happens at most once in a short time, or a chunk that is really missing will reload in a loop.

### How do I reproduce it locally?

Build and serve the app, open it in a tab, then rebuild with a change and replace the served files without the old ones. Navigate in the old tab to a route that loads a chunk. You can also block the chunk URL in DevTools (Network, right-click the request, Block request URL).

### Why does the error say Unexpected token '<' instead?

Some hosts answer a missing chunk with `index.html` and status 200, because of a single-page-app fallback rule. The browser then tries to run HTML as JavaScript. Exclude your asset folder from the fallback so missing files return 404.

## Sources

- [webpack JsonpChunkLoadingRuntimeModule.js](https://github.com/webpack/webpack/blob/main/lib/web/JsonpChunkLoadingRuntimeModule.js) (`ChunkLoadError` message), checked 2 Nov 2026.
- [Turbopack browser runtime](https://github.com/vercel/next.js/blob/canary/turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/runtime/base/runtime-base.ts) (`Failed to load chunk`), checked 2 Nov 2026.
- [Vite: Load error handling](https://vite.dev/guide/build#load-error-handling) and [Vite importAnalysisBuild.ts](https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/importAnalysisBuild.ts) (`vite:preloadError`), checked 2 Nov 2026.
- [TanStack Router commit feb9a84](https://github.com/TanStack/router/commit/feb9a84) (browser messages for failed dynamic imports), checked 2 Nov 2026.
- [MDN: import()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import), checked 2 Nov 2026.
- [Vercel: Skew Protection](https://vercel.com/docs/skew-protection), checked 2 Nov 2026.
- [React error logger](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberErrorLogger.js) and [Next.js error boundary callbacks](https://github.com/vercel/next.js/blob/canary/packages/next/src/client/react-client-callbacks/error-boundary-callbacks.ts), checked 2 Nov 2026.

## Related errors

- [Unexpected token < is not valid JSON](/guides/unexpected-token-not-valid-json/): `SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON`
- ["Script error." from cross-origin scripts](/guides/script-error-cross-origin/): `Script error.`
- [Next.js hydration mismatch](/guides/nextjs-hydration-mismatch/): `Hydration failed because the server rendered HTML didn't match the client`
- [Cannot read properties of undefined](/guides/cannot-read-properties-of-undefined/): `TypeError: Cannot read properties of undefined (reading 'map')`
- [CORS: No 'Access-Control-Allow-Origin' header](/guides/cors-blocked-no-access-control-allow-origin/): `has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.`

All guides: [Fix an error](/guides/).

[Start free](/console/register?utm_source=bugtape_seo&utm_campaign=error-guides). Free plan · No credit card · Set up in minutes. Or [try the live demo](/demo/?from=landing&start=1#capture) first.
