Late one night I opened an article on my own site and got "500 Internal Server Error."
I reloaded, and it rendered as if nothing had happened.
A few minutes later, a different article showed the same screen. That unsettled me. If the server were truly down, it would not fail this capriciously.
As an indie developer I run four Next.js sites on Cloudflare Workers, and I deploy many times a day. For a while I assumed this "sometimes 500, fine on reload" behavior was a caching problem. The short version: the server never returned a 500 at all.
First, question whether it is really a 500
Before trusting what the browser paints, check the HTTP response itself. Skip this and you will spend hours chasing a server error that does not exist.
Pull just the status code for the same URL.
# Browser says "500" — what is the real status?
curl -s -o /dev/null -w "%{http_code}\n" \
https://claudelab.net/articles/claude-code/your-article-slug
# Hit it ten times to look for flapping
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code} " \
https://claudelab.net/articles/claude-code/your-article-slug
done; echoEvery one of the ten responses came back 200.
Malformed cookies, an absurdly long slug, RSC headers — none of it changed anything. The server stayed at 200. The "500," then, was not a server response. It was a picture drawn by something happening inside the browser.
The culprit was a ChunkLoadError
I opened the browser console and captured the logs at the moment the 500 screen appeared. There it was: ChunkLoadError, repeated.
Here is the mechanism. Next.js ships JavaScript as small, hash-named chunks (files like framework-abc123.js). When a new deploy goes out, those file names change.
But the user's browser is still holding the old page. When that page reaches for "the chunk that existed a moment ago," the server has already swapped it for a new name, so it 404s. The JavaScript that failed to load throws, and Next.js fires its client-side error boundary (error.tsx).
At this point the server is clearly off the hook. The more often you deploy, the more moments arise where an old tab collides with a fresh deploy. On my schedule, with several deploys a day, someone was hitting that window every single day.
Why it said "500"
Opening src/app/[locale]/error.tsx made it click.
// Old, problematic version: it branded every client error as "500"
'use client';
export default function Error({ error, reset }: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="error-page">
<h1>500 Internal Server Error</h1>
<p>A server error occurred.</p>
<button onClick={reset}>Try again</button>
</div>
);
}error.tsx renders for any client-side exception — a ChunkLoadError, a hydration mismatch, anything. Yet the heading hard-coded "500 Internal Server Error," so failures that had nothing to do with the server still looked like a server 500.
Worse, reset() only attempts to remount the component; it cannot re-fetch a chunk that already failed to load. That is why "Try again" did nothing, and only a manual reload recovered the page.
Build in an auto-reload so it stops misreporting
The fix has three parts. Since most ChunkLoadErrors clear once the browser fetches the new chunks, reload once automatically on first error. Then drop "500" from the copy, and leave a clue in the console.
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Always record the error name and digest for diagnosis
console.error('[error boundary]', error.name, error.message, error.digest);
// ChunkLoadError comes from stale chunk references — reload once to recover transparently
const isChunkError =
error.name === 'ChunkLoadError' ||
/Loading chunk [\d]+ failed/.test(error.message);
if (isChunkError && typeof window !== 'undefined') {
const KEY = 'cl_chunk_reloaded';
if (!sessionStorage.getItem(KEY)) {
sessionStorage.setItem(KEY, '1');
window.location.reload();
}
}
}, [error]);
return (
<div className="error-page">
<h1>Something went wrong while loading</h1>
<p>The page failed to load. A reload often recovers it.</p>
<button onClick={() => {
sessionStorage.removeItem('cl_chunk_reloaded');
window.location.reload();
}}>
Reload the page
</button>
</div>
);
}The sessionStorage flag is the key detail. Without it, if the same error returns after the reload you fall into an infinite reload loop. Mark the page once it auto-reloads, and if it still fails, hand off quietly to the manual button.
sessionStorage can throw in private browsing, so wrap it in try/catch for production. I do not call reset() here, because it has no effect on a ChunkLoadError.
Do not bake the error screen into the edge cache
There is one more easy thing to miss. If you cache HTML at an edge like Cloudflare, you risk freezing a broken response generated during the split-second of a deploy transition.
In my setup I added a guard so the cache layer refuses to store HTML that carries traces of an error: HTML containing the error-boundary marker (something like data-error-boundary), HTML missing its closing </html>, and article HTML with an empty body are all excluded from caching.
That also closes the path by which a broken page could be served to Googlebot. For crawl health, freezing an error screen was exactly the behavior I wanted to avoid. My broader Cloudflare setup is written up in operational notes for running Next.js on Cloudflare Workers.
A checking order so you avoid the same trap
When the symptom is "sometimes 500, fine on reload," start with curl and confirm the status code. If it returns 200, the server is off the suspect list right there.
Next, look for ChunkLoadError in the browser console. If it is there, the culprit is the mismatch between a deploy and chunk names. Then check whether error.tsx still hard-codes "500," and whether an auto-reload is in place.
I took the display at face value and burned my first few hours on a cache investigation. Simply remembering that the error code on screen and the actual HTTP status are two different things makes the diagnosis far faster.
Relatedly, telling network issues apart is covered in diagnosing Claude Code network connection errors, and the Workers bundle design lives in dodging the 62 MiB limit with a Content Split Architecture.
I hope this gives you a faster first step if the same capricious "500" has been troubling you too.