Fix 'Failed to fetch dynamically imported module' in Vite

This Vite error means an open tab requested a lazy chunk your deploy deleted. Fix it with a vite:preloadError reload handler, router fixes, and cache headers.

Catch Team··7 min read

TypeError: Failed to fetch dynamically imported module means the browser asked for a lazy-loaded chunk that no longer exists on your server. Almost always: you deployed a new build, Vite's hashed chunk filenames changed, your server stopped serving the old files — and a user whose tab predates the deploy clicked a link. The fix is two-sided. In the client, listen for Vite's vite:preloadError event and reload once. On the deploy side, stop making old chunks disappear. Do both.

Why does this error happen after a deploy?

Because Vite fingerprints every chunk filename, and a deploy invalidates the fingerprints that live in your users' open tabs. A lazy route like () => import('./pages/Settings.vue') compiles to a separate file — say assets/Settings-Cab1Twx9.js — and the hash changes whenever the content does. The entry bundle a user loaded this morning holds hard references to those exact filenames. Deploy this afternoon and two things happen: the chunk gets a new hash, and your production URL stops serving the old file — deleted outright, or absent from the new atomic deploy. When that user navigates to Settings, their stale bundle requests Settings-Cab1Twx9.js, gets a 404 (or an index.html fallback that fails the module MIME check), and the import() promise rejects. That's the whole bug.

Each browser words the same chunk load error differently — worth knowing when you grep reports:

BrowserMessage
Chrome / EdgeTypeError: Failed to fetch dynamically imported module: https://app.example.com/assets/Settings-Cab1Twx9.js
FirefoxTypeError: error loading dynamically imported module: https://app.example.com/assets/Settings-Cab1Twx9.js
SafariTypeError: Importing a module script failed.

This is also why you will never reproduce it locally. vite dev doesn't build chunks at all — it serves and transforms modules on demand — and vite preview serves one frozen build. The bug requires two builds and a deploy that lands between a user's page load and their next route change. The only place that sequence exists is production, with real users mid-session.

Fix 1: reload once on vite:preloadError

Vite emits a vite:preloadError event on window when it fails to load a dynamic import. event.payload carries the original error, and calling event.preventDefault() stops the error from being thrown. The standard move is to reload the page — the fresh HTML references the new chunk names, so the next import succeeds.

Don't reload unconditionally, though. If the failure has another cause (user offline, ad blocker, the new deploy is itself broken), an unconditional reload becomes an infinite refresh loop. Guard it:

// main.ts — register before the app mounts
window.addEventListener('vite:preloadError', (event) => {
  const lastReload = Number(sessionStorage.getItem('chunk-reload-at') ?? '0');

  // Already reloaded in the last 10s? Something else is wrong.
  // Let the error throw so your error tracker reports it.
  if (Date.now() - lastReload < 10_000) return;

  event.preventDefault(); // suppress the error — we're handling it
  sessionStorage.setItem('chunk-reload-at', String(Date.now()));
  window.location.reload();
});

This listener reloads the page on the first failed dynamic import, then stands down for ten seconds. A second failure inside that window throws normally — which is exactly what you want, because a reload loop you can't see is worse than an error you can.

Fix 2: handle it at the router

The router-level fix has one advantage over a blind reload: it sends the user to the route they were trying to reach, not the one they were leaving. In vue-router, the URL hasn't changed yet when component resolution fails, so window.location.reload() would land them on the old page. router.onError receives the error plus the target location ((error, to, from) in the source), so you can hard-navigate to the destination instead:

// router.ts
import { createRouter, createWebHistory } from 'vue-router';

const chunkError =
  /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/;

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('./pages/Home.vue') },
    { path: '/settings', component: () => import('./pages/Settings.vue') },
  ],
});

router.onError((error, to) => {
  if (chunkError.test(String(error?.message))) {
    // Full navigation: fresh HTML, fresh chunk names, right route.
    window.location.assign(to.fullPath);
  }
});

export default router;

The regex matches all three browser variants from the table above. Note this can still loop if your HTML itself is cached too aggressively — the hard navigation fetches the same stale index.html, which requests the same dead chunk. Fix 3 closes that hole.

For React with React.lazy, wrap the import factory so a rejected chunk fetch triggers one reload instead of crashing into the nearest error boundary:

import { lazy, type ComponentType } from 'react';

function lazyWithReload<T extends ComponentType<any>>(
  factory: () => Promise<{ default: T }>,
) {
  return lazy(() =>
    factory().catch(() => {
      window.location.reload();
      return new Promise<never>(() => {}); // never settles — page is reloading
    }),
  );
}

const Settings = lazyWithReload(() => import('./pages/Settings'));

If you already ship the vite:preloadError listener from Fix 1, it covers these imports too — the router-level versions give you finer control over where the user lands. Either way, keep exactly one reload guard; two handlers reloading independently is how refresh loops are born.

Fix 3: stop deleting old chunks when you deploy

The client-side fixes paper over a deployment problem you can also fix at the source: keep serving the previous builds' assets. Hashed filenames are content-addressed, so files from five builds can coexist in assets/ without collisions — an old tab keeps working until its next full page load.

  • Self-hosted / S3 / GCS: don't mirror-delete on deploy. aws s3 sync dist/ s3://bucket/ --delete is the classic footgun — drop --delete and prune objects older than a week with a lifecycle rule instead.
  • Cache headers: hashed assets get Cache-Control: public, max-age=31536000, immutable; your HTML gets Cache-Control: no-cache. The Vite docs call this out — if browsers cache the HTML, stale pages keep referencing dead chunks no matter what your server retains.
  • Vercel: Skew Protection pins a session to the deployment it started on — Vercel embeds the deployment ID in the HTML and routes subsequent asset requests to that same deployment. New projects on supported frameworks get it by default.
  • Netlify: deploys are atomic — the production URL serves only the latest deploy, and previous builds live at separate deploy permalinks. You can't keep old chunks at the production URL, so on Netlify the client-side reload handler isn't optional.

You won't find this error in your server logs

This failure never touches your backend. The chunk request hits a CDN or static file host, the 404 happens in the user's browser, and the browser throws the exception inside their tab — your application logs stay spotless while real users sit on a broken route. Worse, it surfaces as an unhandled promise rejection (a dynamic import() returns a promise), so a classic window.onerror handler misses it entirely — we covered that gap in window.onerror vs addEventListener('error').

The only way to know how often this dynamic import error fires after a deploy — and whether your reload handler actually fixed it — is to capture errors from real browsers. A tiny error tracker like Catch does the job: install the SDK, and every chunk failure you let throw gets grouped into one issue with a count of affected users next to it. Set a Slack alert and a spike two minutes after a deploy tells you immediately. The stack traces will point at hashed filenames like Settings-Cab1Twx9.js — that's what source maps in production are for. There's a free tier for hobby apps, and this error alone is a good reason to use it.

Where to start

  • Today: add the vite:preloadError listener with a reload guard — it ends most of the user pain.
  • This week: set Cache-Control: no-cache on HTML, immutable on hashed assets, and stop deleting old chunks on deploy (or turn on your host's skew protection).
  • Ongoing: track the error from real browsers so a regression shows up as a spike, not a support ticket.

Catch Team

Building catch.dev — the tiny SDK for in-app feedback and crash reports.

// More from the blog