I run AI Change Watch, a small independent project that
crawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing
and SDK releases — and records every time one of them changes. It’s Next.js App Router on Cloudflare
Workers via OpenNext.
For a while it would, occasionally, break in the browser after I shipped something. Not on load — on the
next click.
The symptom
You have the site open. You navigate — Home, a provider page, anything with a client transition. Instead
of the page you get a white screen and one line:
Application error: a client-side exception has occurred
(see the browser console for more information)
Reload and it’s gone. Everything works. You can’t reproduce it on demand.
Two properties make this genuinely hard to place:
- It’s intermittent. It hits some tabs and not others, some navigations and not others.
-
The message is deliberately empty. That string is React’s production error boundary refusing to
leak details. It is the same message for every uncaught render error, so it tells you nothing about
which one you have.
The trap: the server is fine, and that misleads you
The first thing I did was look at the server. All of it was clean:
$ wrangler tail changewatch-web
... "exceptions": [] ... "exceptions": [] ... "exceptions": [] ...
Every request. And Home returned 200 across every filter, every provider, every page size I tried. No
500, no thrown exception, no failed render.
That reads as “the server is healthy, so the bug is in my component code” — and it sends you into the
one place the problem is not. It took me longer than it should have to state the actual reason the logs
were empty:
Worker logs never contain client exceptions. The error happened in the browser, after the response
was delivered and logged as a success. A clean server log is not evidence about the client. It’s silence
about the client.
The one request that names it
Open DevTools, keep the Network tab open, and reproduce the navigation. You are looking for a 404 on a
/_next/static/... path:
GET /_next/static/chunks/app/[locale]/layout-4f2c9a1b8e.js 404
If it’s there, you are done diagnosing. That is deployment skew, and the underlying error is a
ChunkLoadError — React caught it, and the boundary printed the generic line.
You can confirm it in the Console, which will have the real message that the page refused to show you:
ChunkLoadError: Loading chunk app/[locale]/layout failed.
(error: https://.../_next/static/chunks/app/[locale]/layout-4f2c9a1b8e.js)
Why a redeploy does this
Next.js splits your app into hashed chunks and loads them on demand. A page you open is not the whole
app — it’s an HTML document plus the chunks needed so far. The chunks for a route you haven’t visited
yet are fetched at the moment you navigate there.
The hash in a chunk’s filename is derived from its contents. Change layout.tsx and
layout-4f2c9a1b8e.js becomes layout-91d0c73aa2.js.
Now put those two facts together with how static assets are hosted. On Workers Assets — and on most
static hosting, this is not Cloudflare-specific — only the current build’s assets exist. Deploying
replaces the set. The old hashed filenames stop resolving.
So the sequence is:
- A browser loads the site on build A. The document and the initial chunks are A’s.
- You deploy build B. A’s chunk files are gone.
- That still-open tab navigates. It requests the chunk name it learned from A.
-
404.ChunkLoadError. Generic error boundary.
Which explains both properties from the top. Intermittent, because it only affects tabs that were
loaded before the deploy and then navigated after it. Fixed by reload, because a reload fetches the
new document, which references B’s chunk names.
It also explains why this feels like it’s getting worse when you’re actively working: during a session
where you deploy six times in an hour, every tab you left open is a candidate. At a normal release
cadence it only touches people who happened to have the site open across the deploy.
Two fixes, and they are not equivalent
Recover automatically. Add an error boundary that recognises this specific failure and reloads
instead of rendering the message:
// app/[locale]/error.tsx
'use client';
export default function Error({ error }: { error: Error }) {
if (error.name === 'ChunkLoadError') {
window.location.reload();
return null;
}
return <p>Something went wrong.</p>;
}
This is the honest fix for a user-facing site: the reload is what the visitor would have done anyway, so
do it for them. The cost is that you have taken a class of error and made it invisible — if something
else ever produces a ChunkLoadError, you now have a silent reload loop instead of a report.
Keep old builds around. Some platforms let you retain previous deployments’ static assets so old
chunk names keep resolving for a while. This removes the 404 rather than reacting to it, and it is
strictly better where it’s available. It was not an option for me.
I went with neither, deliberately. My traffic is small, my deploys are frequent, and the failure costs a
reload — so I recorded what it was, in writing, and stopped investigating it. The expensive part of
this bug was never the impact. It was the three separate times I opened the wrong file looking for it.
The general shape
The thing I’d actually want to keep from this:
A generic error message plus a clean server log is a specific combination, not an absence of
information. It means the failure is downstream of your logs — in the browser, in the CDN, in the
network — and it should send you to a different tool, not to a closer reading of your components. I kept
re-reading the server side because that was the side I could see.
A hashed filename is a contract with a build that may no longer exist. Anything that caches, retries,
prefetches or holds a reference across a deploy is exposed to it. Long-lived tabs are just the most
common case.
Reproduce it before you fix it. You can trigger this on demand: open the site, deploy, then click a
link in the old tab without reloading. If it doesn’t reproduce, you have a different problem, and the
error boundary above would have hidden it from you.
