Field Notes

Fixing React Lazy Load Chunk Errors with Vite and Cloudflare Pages

softwarereactvitecloudflareposthog

Quick Summary

tl;dr: We had a React + Vite app on Cloudflare Pages where stale lazy-loaded chunks were returning 200 text/html instead of 404. The browser was asking for JavaScript and getting the SPA HTML shell. The fix was adding public/assets/404.html so missing /assets/*.js files return a real 404 no-store, while normal client-side app routes still fall back to the React app.

The error messages

I am writing this mostly so other people can search for the exact PostHog / browser errors and hopefully save themselves an hour or two.

These are the strings we saw:

Failed to fetch dynamically imported module
is not a valid JavaScript MIME type
Cannot read properties of undefined (reading 'default')
Invalid source map: bad json: expected value at line 1 column 1

This was in a React app built with Vite, hosted on Cloudflare Pages. We use React.lazy and dynamic imports for route-level code splitting.

So, very normal stack. Nothing weird.

What users actually saw

The product symptom was not super dramatic. This was not like “the whole app is down”.

It was more like:

  1. User opens the app.
  2. User leaves the tab open.
  3. We deploy a new version.
  4. User clicks into a lazy-loaded route.
  5. The app either reloads, hits the error boundary, or gets weird until the user refreshes.

In practice, users probably saw one of these:

  • a brief reload
  • a generic “Something went wrong” page
  • a blank-ish route
  • a page that worked after a manual refresh

PostHog saw a pile of frontend errors. Users saw “this app got weird for a second”.

What was happening

Vite emits content-hashed files like this:

/assets/index-DqWHwWrG.js
/assets/Leads-Bfn8i2kG.js
/assets/EmailCampaigns-CLPMoMmX.js

This is correct. You want these names to change when the content changes.

The annoying failure mode is:

  1. Deployment A has some chunk like /assets/EmailCampaigns-OLD.js.
  2. A user loads the app while deployment A is live.
  3. The user keeps the tab open.
  4. Deployment B goes live and no longer has that old chunk.
  5. The old tab tries to load /assets/EmailCampaigns-OLD.js.
  6. Cloudflare Pages does not find it.
  7. Cloudflare Pages falls back to the SPA HTML shell.
  8. The browser asked for a JavaScript module and got HTML.

That last part is the bug.

Before the fix, this is what a missing asset looked like:

curl -I https://app.example.com/assets/definitely-missing-stale-chunk.js

HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=31536000, immutable

That is really bad. It is a missing JavaScript file, but the server says “200 OK, here is some HTML”.

This explains the MIME error:

is not a valid JavaScript MIME type

The browser is right. It wanted JavaScript and got HTML.

It also explains:

Failed to fetch dynamically imported module

And it can show up around React.lazy as:

Cannot read properties of undefined (reading 'default')

because React.lazy expects the import to resolve to a module with a default export. Once the dynamic import is broken, the error can bubble up in a few different shapes.

The tiny fix

We added this file:

public/assets/404.html

with this content:

Asset not found

That is basically the whole fix.

Cloudflare Pages looks for the closest 404.html file in the request path. So if /assets/some-old-chunk.js is missing, Cloudflare can use /assets/404.html.

The important part is that we did not add:

public/404.html

If you add a top-level 404.html, Cloudflare Pages no longer does the same implicit SPA fallback behavior for app routes. In this case we wanted missing assets to 404, but we still wanted /some/client/route to serve the React app shell.

After the fix, the missing asset looked like this:

curl -I https://app.example.com/assets/definitely-missing-stale-chunk.js

HTTP/2 404
content-type: text/html; charset=utf-8
cache-control: no-store

And fake client routes still worked:

curl -I https://app.example.com/some/client/route

HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate

That is the invariant you want:

/assets/missing-old-chunk.js -> 404
/some/client/route -> 200 app shell

It is a tiny fix, but it changes the failure from “HTML pretending to be JavaScript” to “a missing module is actually missing”.

Keep the reload recovery

You still need to recover the user’s tab.

Vite has a vite:preloadError event for this exact type of problem. The rough shape is:

window.addEventListener('vite:preloadError', (event) => {
  if (isDynamicImportLoadError(event.payload)) {
    window.location.reload()
    event.preventDefault()
  }
})

We also handle chunk-load-looking errors in the global React error boundary because not every browser and not every route failure seems to show up in exactly the same way.

The only detail here is: do not reload in a tight loop.

We store the last chunk error message and timestamp in sessionStorage, and suppress repeats of the same failure for a few minutes. That means a stale tab can recover after a deploy, but one bad chunk does not turn into an infinite refresh loop.

The source map thing

The source map error was related but separate:

Invalid source map: bad json: expected value at line 1 column 1

In our case we were generating source maps for PostHog, uploading them, and then deleting them from the public build output.

That part is fine.

The problem was that the built JavaScript still had sourceMappingURL comments in it. So something would try to fetch a .map URL that did not exist publicly anymore. Then the hosting fallback could return HTML instead of JSON. Then tooling tried to parse HTML as a source map.

The fix was hidden source maps:

build: {
  sourcemap: uploadSourceMaps ? 'hidden' : false,
}

This still creates source maps so they can be uploaded to PostHog, but it does not leave public source map comments in the JavaScript.

The smoke test

I would turn this into a smoke test because it is easy to regress.

The important checks are:

BASE="https://app.example.com"

curl -sS -D /tmp/missing-asset-headers.txt \
  -o /tmp/missing-asset-body.txt \
  "$BASE/assets/definitely-missing-stale-chunk-$(date +%s).js"

grep -q "404" /tmp/missing-asset-headers.txt
! grep -q '<div id="root"' /tmp/missing-asset-body.txt

curl -sS -D /tmp/spa-route-headers.txt \
  -o /tmp/spa-route-body.txt \
  "$BASE/some/fake/client/route"

grep -q "200" /tmp/spa-route-headers.txt
grep -q '<div id="root"' /tmp/spa-route-body.txt

Also check the current entry asset from the HTML and make sure it returns 200 application/javascript.

After deploy, you want:

Missing asset: 404 no-store
Fake SPA route: 200 text/html app shell
Current entry JS: 200 application/javascript

Checklist

If you are debugging this in a React Lazy Load / React.lazy / Vite / Cloudflare Pages app, I would do this:

  1. Search your error tracker for these exact strings:
Failed to fetch dynamically imported module
is not a valid JavaScript MIME type
Cannot read properties of undefined (reading 'default')
Invalid source map: bad json: expected value at line 1 column 1
  1. Curl a missing old asset URL:
curl -I https://your-app.com/assets/definitely-missing-stale-chunk.js
  1. If it returns 200 text/html, add:
public/assets/404.html
  1. Make sure your client routes still return the app shell.

  2. Keep HTML uncached or revalidated:

Cache-Control: no-cache, must-revalidate
  1. Keep real hashed assets cacheable:
Cache-Control: public, max-age=31536000, immutable
  1. Use hidden source maps if you upload source maps to PostHog but do not publicly serve .map files.

  2. Keep the Vite vite:preloadError reload recovery.

This was one of those production bugs where the fix is very small, but understanding where the failure crosses from React to Vite to Cloudflare to the browser module loader is basically the whole job.

References