A client site on Next.js 16 had coin detail pages at /coins/[slug]. The homepage was smooth, no complaints. But the moment I clicked a coin and navigated to /coins/ethereum, the browser only showed a generic message: "This page couldn't load". Same in Brave. Same in Chrome. A hard reload sometimes went through, but navigating between pages fell over instantly.
My first reflex: check from the terminal. I curled the exact same URL and got 200. Clean headers, full body, all the markup in place. To the terminal, the page looked perfectly healthy. So I briefly reached the wrong conclusion: "the server is fine, this must be a client bug." The exact opposite of an old case where curl came back empty while the browser rendered fully. This time curl handed me false confidence.
Why curl says 200 while the browser fails
The key is how Next.js serves two different kinds of request for the same route. Plain curl asks for a full HTML document. When the server render trips on this path, Next often still replies 200 with HTML from an error boundary, so the status code lies. Meanwhile, navigating between pages in the browser does not fetch full HTML. It performs an RSC fetch, a request with an RSC: 1 header, and the RSC stream is much stricter. If the render throws, that stream comes back 500 as-is.
So I repeated the probe, this time matching what the browser does:
curl -I https://client-site.example/coins/ethereum
# HTTP/2 200
curl -I -H "RSC: 1" https://client-site.example/coins/ethereum
# HTTP/2 500There the bug stepped out of hiding. Normal request 200, RSC request 500. Buried in the RSC stream was digest: '1424695691', but the digest alone tells you nothing beyond "there is an error obfuscated in production". I needed the real stderr.
Catching the real error
That production digest is deliberately opaque. To read the actual message, I reproduced the build conditions locally instead of guessing from pnpm dev:
pnpm build && pnpm startOnce the production server ran on my machine and I opened /coins/ethereum, stderr coughed up two distinct things. The first was a very specific warning from Next:
Route /coins/[slug] used "revalidateTag apiSettings" during render which is unsupported.The second, tucked between other log lines, was an error from the cache layer:
ERR max requests limit exceededTwo signals, two bugs that happened to stack in the same week.
Two bugs stacked
The first bug lived in the data fetchers. Every time an API provider was called, there were markProviderSuccess and markProviderError functions that recorded provider health, and both of them called this:
function markProviderSuccess() {
// called from inside a fetcher, mid-render
revalidateTag("apiSettings", "max");
}In Next.js 16, calling revalidateTag during render is forbidden. revalidateTag is a cache-mutation operation, and its place is a Server Action or a Route Handler, not the middle of a server component render. Because this fetcher ran while rendering /coins/[slug], Next rejected it and the render failed.
The second bug made it worse. This site's cache layer used Upstash Redis on the free tier, 500k requests per month. That week the quota ran out, and every redis.get, redis.set, and redis.incr started throwing ERR max requests limit exceeded. That error was never caught, so it propagated unimpeded into the RSC stream and took the render down with it.
The combination explains why plain curl still returned 200: a full HTML response hides render errors behind status 200, while the RSC response enforces the rules and answers 500.
The fix
Three steps, in the order the causes appeared.
First, get revalidateTag out of the render path. Marking a provider as healthy or failed does not need to purge cache mid-render. I moved that invalidation into a Route Handler, which is allowed to mutate the cache, and the fetcher now only reads cache without writing back:
function markProviderSuccess() {
// no revalidateTag here anymore
metrics.record("provider_ok");
}Second, wrap every Redis touch so a cache failure falls through cleanly as a miss instead of dropping the page:
async function safeGet(key: string) {
try {
return await redis.get(key);
} catch {
return null; // treat as a cache miss, let the render fall through to origin
}
}The same pattern for set and incr: try, catch, and if it fails swallow it quietly instead of letting the error rise into the stream. The cache is an optimization, not a life-support requirement for the page.
Third, bump the Upstash quota so the cache layer stops hitting the ceiling under normal load. But that is hygiene, not the core fix. The core fix is the first two: the render no longer tries to mutate the cache, and a dead cache can no longer take the render down.
Takeaways
- If
curlreturns200but the browser fails to navigate, do not trust that200. Re-probe with the RSC header:curl -H "RSC: 1". - A
200on a full HTML response can hide a render crash. The RSC stream is stricter and will surface the real500. - Never call
revalidateTagduring render. Its home is a Server Action or a Route Handler, not a server-component fetcher. - Wrap cache calls (
get,set,incr) in try/catch that falls back to a cache miss. A dead cache should slow you down, not shut you down. - Reproduce production-only bugs with
pnpm build && pnpm start, do not lean on the too-permissivepnpm dev. - Production error digests are opaque on purpose. Read the real stderr for a message that means something.
