Next.js

Fix Failed to fetch dynamically imported module (Vite)

Reproduce the production-only Vite error in Chromium, see why retrying the same import does nothing, and ship a reload handler that cannot loop.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

The browser asked for a hashed chunk such as SearchResultPage-c1900fe3.js that the server no longer has. The tab was opened before your last deploy, its JavaScript still points at the old file names, and the new build replaced them. Serve index.html with Cache-Control: no-cache, and add a vite:preloadError listener that reloads the page once, so the tab picks up the new HTML and the new chunk names. Retrying the same import does not help after a deploy, because the file is gone.

The Stack Overflow question describes the classic case: five routes loaded with React.lazy, a Vite build that writes into the backend's src/main/resources/static folder with emptyOutDir: true, and Sentry reports, in production only, a TypeError with this message:

text
Failed to fetch dynamically imported module: https://help.example.io/static/js/SearchResultPage-c1900fe3.js

The behaviour below was reproduced on 15 October 2026 with Vite 8.3.4, Node 22.23.3 and Chromium 155.0.8059.39 (the Debian package, driven by Puppeteer) in a throwaway Docker container. The test app uses a plain import() behind a button rather than React.lazy; the failing request is the same.

Why it happens#

A production Vite build names each chunk after a hash of its content. The asker's config makes that explicit with chunkFileNames: "static/js/[name]-[hash].js". Change one route, rebuild, and the route's chunk gets a new name. The entry chunk that imports it changes too, because it now contains the new file name.

A user who loaded the site before the deploy still runs the old entry chunk. When they navigate to a lazy route, it requests the old chunk name. The Vite build guide describes exactly this: "the hosting service may delete the assets from previous deployments", so the user's outdated code "tries to import the corresponding old chunk, which is deleted". In the question, the deletion is configured explicitly. emptyOutDir: true empties the output folder on every build. Per the build.emptyOutDir reference, Vite only does this by default when the output folder is inside the project root, and this output folder is outside it.

This is what an old tab sees after a redeploy, with no handler installed:

text
$ vite build (deploy v1) -> dist/assets: index-CiyCHhq-.js page-BzSjuU40.js
$ vite build (deploy v2) -> dist/assets: index-Bbext02G.js page-BsZEZ-a-.js
[server] 404 /assets/page-BzSjuU40.js
click in old tab -> TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:4173/assets/page-BzSjuU40.js

The development server never shows this because it serves modules on request from source. There is no hashed file left over from an earlier build to go missing.

Why a retry in the same tab does not work#

The top answer on the question links whatwg/html issue 6768, "Failed dynamic import should not always be cached". The HTML standard used to keep a failed module fetch in the page's module map, so importing the same URL again returned the same failure. The reproduction confirms it on Chromium 155. After the old chunk had been copied back onto the server, a second click in the same tab failed again, and the server log shows no request at all. Only a page reload fetched anything:

text
-- restore the v1 chunk on the server, click again in the same tab --
retry in same tab -> TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:4173/assets/page-BzSjuU40.js
[server] 200 /assets/index-Bbext02G.js
[server] 200 /assets/page-BsZEZ-a-.js
after page.reload() -> page from deploy v2

The standard has since changed. whatwg/html PR 10327, "Don't cache HTTP errors in the module map", was merged on 15 July 2026, and MDN's browser compatibility data for import() (the raw entry, shown on the MDN page) lists retriable loading from Chrome 156 and Firefox 155. That makes retries useful for a flaky connection or a transient 503. It does not rescue a deploy: the retried URL is still the deleted file, so it 404s again. The cure for this error is new HTML, not a second attempt at the old URL.

Fix#

  1. Stop caching the HTML. If the browser or a CDN keeps an old index.html, a reload returns the same old chunk names and nothing improves. The Vite guide says to "make sure to set Cache-Control: no-cache on the HTML file". Hashed assets can keep a long cache lifetime. On nginx, for example, this server block sends the header on the HTML and answers a missing asset with a real 404 instead of the SPA fallback:

    nginx
    server {
        listen 80;
        root /usr/share/nginx/html;
        location / {
            try_files $uri $uri/ /index.html;
        }
        location = /index.html {
            add_header Cache-Control "no-cache";
        }
        location /assets/ {
            try_files $uri =404;
        }
    }

    Checked on nginx 1.30.5 (the official Alpine image): the root URL, an app route such as /search/foo and /index.html all came back with the no-cache header, because the fallback ends in an internal redirect to /index.html. Without the /assets/ block, a missing chunk came back as 200 with an HTML content type. With it, the chunk returned 404. One nginx detail matters here. The headers module documentation says these directives are inherited from the previous level "if and only if there are no add_header directives defined on the current level". So an add_header inside this location drops any header set at server level, such as a Content-Security-Policy. Repeat them there, or change the rule with the add_header_inherit directive that the same page documents.

    For the asker's setup, where the backend serves the built files, the same header has to come from the backend's static-file configuration for the HTML document.

  2. Reload once when a dynamic import fails. Vite wraps the dynamic imports in your build with a helper that, on failure, dispatches a vite:preloadError event on window before rethrowing. The Vite guide's example is a bare window.location.reload(). The version below adds a 10-second guard, an addition of this article rather than Vite's, so that a reload which brings back the same stale HTML cannot loop. Put it in your entry file, before the app renders:

    js
    window.addEventListener('vite:preloadError', () => {
      const key = 'vite-preload-reload-at'
      const last = Number(sessionStorage.getItem(key) || 0)
      // A reload less than 10 s ago did not help: let the error reach the app.
      if (Date.now() - last < 10_000) return
      sessionStorage.setItem(key, String(Date.now()))
      window.location.reload()
    })

    The guard stores a timestamp rather than a flag. A flag that is never cleared would let the first deploy reload the tab and then block every later deploy for the rest of the session.

  3. Optionally keep the previous deploy's chunks. If old files stay on the server for a while, old tabs keep working without a reload. When you build in place on the machine that serves the files, as the asker does, set emptyOutDir: false and prune old files on your own schedule; otherwise the folder grows with every build. Removing the line is not enough for an output folder inside the project root, where Vite empties it by default. When a hosting platform replaces the assets on each deploy, keeping old chunks is a setting of that platform, not of Vite. Either way, this complements step 2 rather than replacing it.

  4. Give React a fallback for the case that still fails. The React lazy reference says that the returned promise is cached, so React "will not call load more than once", and that a rejection is thrown "for the nearest Error Boundary to handle". So wrap the lazy routes in an error boundary that offers a reload button. Do not expect lazy to try again by itself. The second answer on the question recommends the same: error boundaries for the user experience, or static imports if the bundle stays small.

Verify the fix#

Reproduce the deploy locally before trusting it in production:

  1. Build and serve the app with HTML served as no-cache, and open a page that has not loaded the lazy route yet.
  2. Change the lazy component, rebuild into the same folder so the old chunk is deleted, and keep the tab open.
  3. Navigate to the lazy route in the old tab. In DevTools, the Network panel should show a 404 for the old chunk, then a new document request. The route then renders from the new chunk.

With the listener from step 2, the reproduction ended like this, including a second deploy in the same tab after the 10-second window had passed, which the timestamp guard allows. The browser's HTTP cache was disabled for this tab so that the second deploy had to fetch its chunk; the earlier run without that setting is described below.

text
[server] 404 /assets/page-BzSjuU40.js
[server] 200 /assets/index-BZD9LPLn.js
click in old tab -> reloaded
[server] 200 /assets/page-BsZEZ-a-.js
click after the automatic reload -> page from deploy v2
-- 11 s later: reload the tab on v2 (nothing imported yet), then deploy v3 --
[server] 404 /assets/page-BsZEZ-a-.js
[server] 200 /assets/index-D-pcHhCe.js
click in old tab -> reloaded
[server] 200 /assets/page-Cs1bAxqJ.js
click after the automatic reload -> page from deploy v3

If your app is Next.js rather than Vite, the same version skew surfaces as ChunkLoadError, and Next.js has its own deployment-ID mechanism for it. That is covered in the Next.js ChunkLoadError fix.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

When the old chunk returns index.html instead of a 404#

A host configured with a single-page-app fallback answers every unknown path with index.html and status 200, as the first nginx config above did before the /assets/ block was added. For a missing chunk, Chromium then rejects the response because of its MIME type. The console line differs from the 404 case, but the import fails with the same message:

text
[server] 200 index.html for missing /assets/page-BzSjuU40.js
[console.error] 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.
click in old tab -> TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:4173/assets/page-BzSjuU40.js

vite:preloadError still fired in this setup, and the reload recovered the tab. A fallback that rewrites asset paths still hides the real problem from your monitoring, though, so restrict it to navigation routes if you can.

Two more behaviours showed up in the runs. In an earlier run with the HTTP cache left on, the second deploy produced no error at all. The tab had already fetched the v2 chunk, served with a one-year immutable max-age, and a fresh import read it from the cache without asking the server, even though the deploy had deleted it. A deleted chunk only breaks for a tab that never fetched it. And when the host keeps serving the old index.html, the reload brings back the same old chunk names. The guard then stops after one attempt and the error reaches the app:

text
click in old tab -> reloaded
[server] 404 /assets/page-BzSjuU40.js
click after the reload (same old HTML) -> no reload
#out shows -> TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:4173/assets/page-BzSjuU40.js

That last case points to step 1, not to the listener. Check your HTML caching first.

This error belongs to the same family as other bugs that only appear once Vite builds for production, such as import.meta.env values that come back undefined, and to deploy problems where the build context differs from your machine, such as a Docker build that cannot find index.html. Here, though, the build is fine and only the timing of the deploy breaks it. Send fresh HTML and reload once on failure, and a deploy stops breaking open tabs.

Further reading: Fix "only exports components": Fast Refresh ESLint error.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote