Knowledge Mark G
Hire Me

ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML

The page loads with no styling and the console says ChunkLoadError. The build is fine and the files are on the server. The HTML asking for them is three hours old and came from your CDN, and it is still naming the chunks from the previous build.

Kausar RazaBy Published on · 7 min read · 5 views
Share:

Web Development

ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML
  • Next.js 15 Tutorial
  • Performance
  • DevOps
  • Web Development

Need help with this?

I build and fix business websites - fast, mobile-first and built to bring enquiries.

Get in touch

You deploy. The site comes back with no styling, or half the interactive bits are dead, and the console says:

ChunkLoadError: Loading chunk 478 failed.
(missing: https://example.com/_next/static/chunks/3ivcc6t1s04dh.js)

You check the server. The file is not there — but a file with a different name is, and it contains exactly the code that is missing. Rebuild, redeploy, same thing. Open the site in a private window and it is perfect.

Almost everything written about this error blames the service worker, or webpack's output.publicPath, or tells you to catch the import and retry. On a Next.js site behind a CDN the cause is usually none of those.

Why the chunk name changes every build

Next.js fingerprints its JavaScript. A real filename from this site:

/_next/static/chunks/3ivcc6t1s04dh.js

That name is derived from the file's contents. Change one line of code and the chunk gets a new name, because the point of the hash is that the old URL can be cached forever and never go stale. It is a good design, and it has one consequence people forget: the HTML is the only thing that knows the current chunk names.

Every page ships a <script src> naming the exact chunks that build produced. Deploy a new build and those names change. The old chunks are gone, because your deploy replaced the folder.

So if a visitor is handed yesterday's HTML, it asks for yesterday's chunks, and yesterday's chunks 404. The browser calls that ChunkLoadError. Nothing is wrong with the build.

Who is handing out yesterday's HTML

Normally nobody. HTML is not supposed to be cached hard — that is what the hashed assets are for. But it is very easy to end up caching it by accident:

  • A CDN cache rule that matches everything. This is what happened here. A Cloudflare Cache Rule was added to cache HTML at the edge, with the edge TTL taken from the origin's s-maxage. It worked exactly as configured — including after a deploy.
  • A reverse proxy with proxy_cache on the HTML location. Same shape, different box.
  • A hosting panel's "performance" or "page cache" toggle, which usually means "cache HTML" without saying so.
  • A service worker serving a cached shell. This is the one everyone writes about, and it is the least likely of the four if you did not add one deliberately.

One request tells you which world you are in:

curl -s -o /dev/null -D - https://example.com/ | grep -i 'cf-cache-status\|^age:'

On this site that returns:

cf-cache-status: HIT
Age: 145

HIT means the HTML came from the edge, not from your app. Age is how many seconds ago it was stored — and if that number is larger than the time since your deploy, you have found your problem. Any CDN exposes an equivalent; look for X-Cache, CF-Cache-Status, or a growing Age on repeat requests.

Narrowing it down in one pass

Four caches can produce this, and they need different fixes. The response headers separate them faster than reading any code:

What you seeWhich cacheFix
cf-cache-status: HIT, Age older than your deploy CDN edgePurge the CDN
No CDN header, Age grows on repeat requests A reverse proxy in front of the appStop caching HTML in the server block
x-nextjs-cache: STALE or HIT Next.js's own ISR cacheRequest again; or revalidate the route
Headers all look current, only some visitors affected A service worker on their machineFix the worker's update strategy

The service worker is the one every search result talks about, so it is worth saying plainly when it is the answer: only if you registered one. A Next.js app does not ship a service worker by default. If you added one through a PWA plugin and it uses a cache-first strategy for navigation requests, it will keep serving an old shell long after the CDN is clean — and the giveaway is that the problem follows particular people rather than particular pages. DevTools → Application → Service Workers, tick "Update on reload", and reload.

The fix, and the habit

The fix is one action: purge the CDN cache as the last step of every deploy. On Cloudflare that is Caching → Configuration → Purge Everything. Not "purge by URL" — you do not know which pages a visitor will land on, and every cached page is naming dead chunks.

Then prove it, because a purge that did not happen looks identical to one that did:

for i in 1 2; do
  curl -s -o /dev/null -D - https://example.com/ | grep -i cf-cache-status
done

The first request should be MISS (or EXPIRED) and the second HIT. Two HITs in a row straight after a deploy means the purge did not take.

Pages that must never be cached should say so themselves rather than relying on a rule. On this site /blog, /pricing and /search send private, no-cache, no-store and show cf-cache-status: BYPASS. That is worth checking deliberately — a logged-in page served from a shared edge cache is a much worse problem than a missing stylesheet.

If you cannot purge as part of the deploy, do not cache the HTML. The hashed assets are where the win is anyway; caching the document saves a few milliseconds and costs you this.

Let the origin decide, not a dashboard rule

The sturdier version of this is to stop expressing the policy as a CDN rule at all, and send it from the application instead. Two headers do the whole job, and they say opposite things on purpose:

/_next/static/...   Cache-Control: public, max-age=31536000, immutable
/  (the document)   Cache-Control: public, s-maxage=300, stale-while-revalidate=...

The first is safe precisely because the filename contains a hash — a given URL's contents can never change, so a year is not a risk. The second lets the edge hold the document briefly and serve a slightly stale copy while it fetches a new one, which is the behaviour you actually want between deploys.

A CDN configured to respect the origin then needs no per-site rules, and a page that must never be cached can opt out by itself. That is how /blog on this site ends up BYPASS — it sends no-store, and nothing in a dashboard has to remember that it is special.

It still does not remove the need to purge after a deploy. Even a 300-second edge TTL is 300 seconds of visitors being handed dead chunk names.

A deploy order that does not produce this

  1. Build with a cold data cache — rm -rf .next/cache/fetch-cache before npm run build, for the reason in the next section.
  2. Check the build output matches reality before shipping it. Count the pages it prerendered against what you expect.
  3. Upload the new files, leaving the old ones in place where you can. Hashed chunks have unique names, so the old and new sets can coexist — and a visitor mid-session still holding old HTML keeps working.
  4. Purge the CDN. Last, after the files are live, never before.
  5. Verify with two requests, as above.

Step 3 is the one people skip because deleting everything feels cleaner. It is also the step that turns a five-minute window of broken pages into none, and on this stack deleting the whole folder has already cost real data once.

The same bug wearing a different hat: a stale build cache

There is a second way a deploy ships something old, and it catches people who have already fixed the first one.

Next.js keeps the data it fetched during a build in .next/cache/fetch-cache, and that directory survives between builds. A fetch with revalidate: 3600 that was made an hour ago is still considered fresh, so a brand new build can prerender pages from the previous build's API responses.

I reproduced this an hour before writing this post. A build run with a warm cache prerendered 38 tool pages. The live API returns 54. The code was correct; the build was answering from a cached copy of the catalogue. Deleting the directory and rebuilding produced all 54.

rm -rf .next/cache/fetch-cache
npm run build

Nothing warns you. The build succeeds, exit code 0, no warnings — it just quietly ships a sitemap that is missing a third of your pages. If your build fetches a catalogue, a menu or a list of posts, clear this before a release build.

The pattern worth keeping

Three times now on this site a "deployment bug" has turned out to be something cached: a page that would not update was Next.js's own cache serving STALE, a half-updated site was Windows locking files the running app had open, and this one was the CDN.

The habit that catches all of them: when a deploy produces a result that makes no sense, read the response headers before you read your code. cf-cache-status, x-nextjs-cache, age and etag will tell you in one request whether you are even looking at the build you just shipped. Most of the time you are not, and there is no bug to find.

Frequently asked questions

Why does a hard refresh fix it for me but not for visitors?+
A hard refresh asks the CDN for a fresh copy for you. Everyone else is still served the cached HTML until it expires or you purge it. That is also why the site looks fine in a private window - it is the same cache, not your browser.
Should I just disable caching for HTML?+
If you cannot purge reliably as part of your deploy, yes. The large win from a CDN is the hashed static assets, which can be cached for a year safely. Caching the document saves a little latency and introduces this failure.
How do I know whether it is the CDN or a service worker?+
Check the response headers first. A CF-Cache-Status or X-Cache HIT, or an Age header that grows on repeat requests, means a shared cache answered. If the headers look fresh and only some visitors are affected, look at the service worker in DevTools → Application.
Does purging by URL work instead of purging everything?+
Rarely. Every cached page references the old chunk names, not just the one you changed, so you would have to purge every page you have ever served. Purge everything after a deploy and let the edge refill.
Why did my build prerender fewer pages than my API returns?+
Almost certainly .next/cache/fetch-cache. Next keeps fetched API responses between builds and honours their revalidate window, so a new build can render from the previous build's data. Delete the directory before a release build.
Is ChunkLoadError always a caching problem?+
No. It can be a genuinely failed network request, an ad blocker, or a misconfigured publicPath when assets are served from another domain. But if it started right after a deploy and goes away on its own after a while, that time is a cache TTL expiring.
Can I automate the purge?+
Yes - Cloudflare has an API endpoint for purge_cache, and most CDNs have an equivalent. Put it at the very end of the deploy script, after the files are in place, and have it fail loudly. A purge that silently did not run looks exactly like one that did.

Join the conversation

No comments yet — be the first to share what you think.

Leave a comment

Never published.

Optional.

Keep reading

Related articles

View all