+1 (415) 779-8456

ChunkLoadError After Deploy: Cache Headers, page-data, and Stale Gatsby Assets

Your Gatsby site builds fine, deploys fine, and looks fine when you check it. Two hours later a client emails a screenshot: a blank page, or a nav link that does nothing, and in the console

ChunkLoadError: Loading chunk 472 failed.
(missing: https://www.example.com/component---src-templates-post-js-9f2a1c.js)

Nothing is wrong with the new build. The problem is the old build: a browser tab that has been open since before the deploy is still running last week's JavaScript, and that JavaScript asks for filenames that no longer exist. This is the single most common "the site is broken but I can't reproduce it" bug on long-lived Gatsby sites, and it is almost entirely a caching and deployment-retention problem rather than a code problem.

Here is how to diagnose it, get the cache headers right, and make the failure recoverable when it still happens.

Why a static site can break itself

A Gatsby build emits three classes of file, and they have completely different caching requirements.

FileExampleFilename changes per build?
Hashed JS/CSS chunkscomponent---src-templates-post-js-9f2a1c.jsYes — content hash in the name
Unhashed runtime/app bundlesapp-<hash>.js, webpack-runtime-<hash>.jsHash changes, but they are referenced from HTML
HTML and page dataindex.html, page-data/blog/some-post/page-data.jsonNo — stable paths, changing contents

Client-side navigation in Gatsby does not fetch HTML. When you click an internal <Link>, the runtime fetches page-data/<path>/page-data.json, reads the component chunk name out of it, and dynamically imports that chunk. So a stale tab holds a stale runtime, which asks for a stale page-data.json path (fine, the path is stable) and gets back a fresh JSON that names fresh chunks — or, worse, gets a cached old JSON naming chunks that were deleted at deploy. Either way the dynamic import 404s and React never renders the new route.

Three independent mistakes produce this:

  1. HTML or page-data.json served with a long cache lifetime. Now the browser keeps serving last week's HTML, which references deleted app-*.js.
  2. Hashed chunks served with a short lifetime or no cache headers at all. Wasteful, and it makes the failure intermittent and hard to reproduce, because whether it breaks depends on what the CDN happens to still hold.
  3. The deploy deletes every file from the previous build. Any tab open across the deploy boundary is guaranteed to fail its next navigation.

Reproduce it in five minutes

You cannot fix this reliably until you can trigger it on demand.

# build and serve build A
npx gatsby build && npx gatsby serve --port 9000

Open http://localhost:9000, leave the tab on the home page, then in another terminal change any file under src/templates/, rebuild, and restart the server:

echo "// cache-bust $(date +%s)" >> src/templates/post.js
npx gatsby build && npx gatsby serve --port 9000

Go back to the still-open tab and click through to a post. In the network panel you will see the request for the old component chunk return 404 and a ChunkLoadError in the console. That is exactly what your users are seeing in production; the only difference is that in production a CDN is also involved.

The cache header matrix

Gatsby's own caching guidance boils down to three rules, and every host needs them expressed in its own syntax.

  • /*.html, /page-data/*, /app-data.json, service worker files: never cached (no-cache — revalidate every time).
  • Hashed JS/CSS and static media: immutable, one year.
  • Everything else: sensible short defaults.

Note that no-cache is not the same as no-store. no-cache lets the browser keep the file and revalidate with an ETag, so you get a cheap 304 rather than a full re-download. Do not use no-store here; you will make every navigation slower for no benefit.

Netlify / static/_headers (put the file in static/ so Gatsby copies it into public/):

/*
  Cache-Control: public, max-age=0, must-revalidate

/static/*
  Cache-Control: public, max-age=31536000, immutable

/*.js
  Cache-Control: public, max-age=31536000, immutable

/*.css
  Cache-Control: public, max-age=31536000, immutable

/page-data/*
  Cache-Control: public, max-age=0, must-revalidate

/app-data.json
  Cache-Control: public, max-age=0, must-revalidate

/sw.js
  Cache-Control: public, max-age=0, must-revalidate

Order matters: the later, more specific rules for /page-data/* must come after the blanket /*.js rule, or JSON page data inherits immutable and you are back where you started.

Nginx:

location ~* ^/page-data/.*\.json$ {
  add_header Cache-Control "public, max-age=0, must-revalidate";
}

location = /app-data.json {
  add_header Cache-Control "public, max-age=0, must-revalidate";
}

location ~* \.(js|css|woff2|avif|webp|png|jpg|svg)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}

location ~* \.html?$ {
  add_header Cache-Control "public, max-age=0, must-revalidate";
}

S3 + CloudFront: set the headers at upload time, because CloudFront will happily pass through whatever S3 says. Two passes, ordered so the HTML/JSON pass wins:

aws s3 sync public/ "s3://$BUCKET" --delete \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.html" --exclude "page-data/*" --exclude "app-data.json" --exclude "sw.js"

aws s3 sync public/ "s3://$BUCKET" \
  --cache-control "public, max-age=0, must-revalidate" \
  --exclude "*" --include "*.html" --include "page-data/*" \
  --include "app-data.json" --include "sw.js"

aws cloudfront create-invalidation --distribution-id "$DIST" \
  --paths "/*.html" "/" "/page-data/*" "/app-data.json" "/sw.js"

Invalidate only the uncacheable paths. Invalidating /* on every deploy costs money and throws away a cache you deliberately marked immutable.

Verify rather than trust:

BASE=https://www.example.com
for p in / /page-data/index/page-data.json /app-data.json; do
  echo "== $p"
  curl -sSI "$BASE$p" | grep -iE 'cache-control|age|etag'
done
curl -sSI "$BASE/$(ls public | grep -m1 '^app-.*\.js$')" | grep -i cache-control

Add that loop to your post-deploy smoke test. Header config is exactly the kind of thing that silently regresses when someone migrates hosts or edits a Terraform module.

Keep the previous build's assets around

Correct headers stop the browser from caching itself into a broken state. They do not help a tab that was loaded ten minutes before the deploy: that tab legitimately needs component---src-templates-post-js-9f2a1c.js, and if your deploy deleted it, no header can help.

Options, in order of how much we like them:

  • Atomic deploys with retained history. Netlify, Cloudflare Pages, and Vercel keep previous deploys addressable, but for the live URL they serve only the current one, so old chunks still 404. This is why the runtime fallback in the next section is not optional on those hosts.
  • Additive uploads with a retention window. For self-managed S3/nginx hosting, drop --delete from the sync and prune with a lifecycle rule instead — expire objects older than, say, 30 days. Old hashed chunks are tiny, unreachable from any current HTML, and keep stale tabs working through the entire window. This is the single most effective fix available if you control the bucket.
  • Deterministic chunk names. Tempting, and wrong: the same filename with different content plus immutable headers is a much worse bug than a 404.
# additive deploy: never delete, let lifecycle rules prune
aws s3 sync public/ "s3://$BUCKET" \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.html" --exclude "page-data/*" --exclude "app-data.json"

Make the failure recoverable, not fatal

Whatever your hosting, assume a chunk will eventually 404 and handle it. The fix is to reload the page once — a reload fetches fresh HTML, which references the new runtime, which works. The trick is the "once": an unconditional reload on error gives you an infinite refresh loop if the real problem is something else.

// src/utils/chunk-recovery.js
const KEY = 'chunk-reload-at';
const WINDOW_MS = 10_000;

export function recoverFromChunkError(error) {
  const message = String(error?.message || error);
  const isChunkError =
    /Loading chunk|ChunkLoadError|Loading CSS chunk|dynamically imported module/i.test(message);
  if (!isChunkError) return false;

  const last = Number(window.sessionStorage.getItem(KEY) || 0);
  if (Date.now() - last < WINDOW_MS) return false; // already tried; do not loop

  window.sessionStorage.setItem(KEY, String(Date.now()));
  window.location.reload();
  return true;
}

Wire it into gatsby-browser.js, which is where Gatsby exposes the relevant lifecycle hooks:

// gatsby-browser.js
import { recoverFromChunkError } from './src/utils/chunk-recovery';

export const onClientEntry = () => {
  window.addEventListener('error', (event) => {
    recoverFromChunkError(event.error || event.message);
  });
  window.addEventListener('unhandledrejection', (event) => {
    recoverFromChunkError(event.reason);
  });
};

// Gatsby calls this when a service worker has fetched a new version
export const onServiceWorkerUpdateReady = () => {
  window.location.reload();
};

A sessionStorage guard, not localStorage: the state should die with the tab, so a user who hits a genuinely broken deploy tomorrow still gets one recovery attempt.

If you also want a visible fallback rather than a silent reload, wrap the page in an error boundary via wrapPageElement and render a "This page has been updated — reload" button. On content sites we generally prefer the silent reload; on anything with a form that could lose user input, prefer the button.

The service worker trap

If the site ever shipped gatsby-plugin-offline, a service worker is installed in browsers you will never see, and it can serve a cached shell indefinitely — including after you remove the plugin. Removing it from gatsby-config.js is not enough. Swap it for the explicit un-installer, ship that for at least as long as your typical return-visit interval, and only then delete it:

// gatsby-config.js
plugins: [
  // 'gatsby-plugin-offline',        // removed
  'gatsby-plugin-remove-serviceworker', // actively unregisters the old SW
],

Confirm in a browser profile that has visited the old site: DevTools → Application → Service Workers should show none registered, and /sw.js should return a script whose only job is self.registration.unregister().

A short checklist

  1. Reproduce locally with two builds and one stale tab.
  2. Fix headers: no-cache for HTML, page-data/*, app-data.json, sw.js; immutable for hashed assets.
  3. Assert those headers in CI after every deploy.
  4. Stop deleting old hashed assets on deploy; prune on a 30-day lifecycle instead.
  5. Add the guarded chunk-error reload and onServiceWorkerUpdateReady.
  6. Unregister any legacy service worker deliberately.
  7. Stop filtering ChunkLoadError out of your error tracker — a spike right after a deploy is the signal that one of the above regressed.

Done properly, the class of bug disappears: users who leave a tab open across a deploy get one invisible reload instead of a blank screen, and your CDN keeps serving hashed assets from cache for a year.

If you are chasing intermittent blank pages, post-deploy console errors, or cache behaviour you can't explain on a Gatsby or static site, get in touch — it is usually an afternoon's work to find and a one-line config change to fix.