I run a directory site: ~3,500 rows in Postgres, read over PostgREST, rendered by Next.js with revalidate on every route. The headline number in the shell says "Search all 3,516 listings."
For about a day it said "Search all 0 listings" — next to a grid full of listings.
The database was fine by then. Three separate bugs conspired, and all three are the same mistake wearing different clothes: a read that degrades politely on failure, sitting underneath a cache that stores the polite answer.
Bug 1: the cache dropped the header the count lived in
PostgREST returns an exact row count in the Content-Range response header if you ask for it, which is far cheaper than selecting rows and counting them:
const res = await fetch(`${URL}/rest/v1/${table}?${query}&select=id&limit=1`, {
headers: { apikey: KEY, Prefer: 'count=exact', Range: '0-0' },
next: { revalidate: 3600 }, // ← the bug
});
const total = Number(res.headers.get('content-range').split('/')[1]);
Next's fetch cache stores the response body and replays it behind a synthetic set of headers. content-range is not one of them. So the first call worked, and every cache hit after it read null and produced 0.
Statically prerendered pages looked perfect — their single fetch happened at build time and was always a miss. Every dynamic route showed zero. If you're caching a fetch for anything that isn't in the body, cache the parsed value instead: mark the fetch cache: 'no-store' and wrap the function in unstable_cache. Cache things that survive being cached.
Bug 2: the zero got memoised for an hour
With the count now memoised, the failure path mattered. The read returned 0 when the response wasn't OK — sensible-looking, and unstable_cache dutifully stored that 0 for the full hour.
So the wrong number outlived the outage. The index came back; the site kept saying zero, and looked completely healthy while doing it. For a directory, "0 listings" isn't a degraded state, it's a false claim about the world.
Fix: throw inside the cached function. unstable_cache stores nothing on a throw, so the next request re-reads and the site heals the instant the database answers.
Bug 3: the page containing the zero was also cached
Here's the one I got wrong twice. The outer catch still returned 0, on what felt like solid reasoning: this zero isn't memoised, so it lasts exactly as long as the outage.
It missed the other cache. Every route declares revalidate. A background revalidation that renders "Search all 0 listings" writes that sentence into the ISR page cache and serves it for an hour on the landing and a day everywhere else. Not caching the zero doesn't help when the page containing the zero is the cached artifact.
The worst instance was /opengraph-image, which drew "0 skills · 0 subagents · 0 plugins" onto the share card and pinned it to every link preview for a day. A share card is the one surface where a wrong number gets screenshotted and outlives your cache entirely.
So it rethrows. Throwing is what makes Next keep the last good copy.
The rule that fell out
Not every read should fail loudly. Two here must degrade: the footer's freshness line, which appears on ~3,500 routes and is one sentence of fine print, and the build-time list behind generateStaticParams, which only decides what gets prerendered. Those go through a separate helper with AbortSignal.timeout(5000) — a plain fetch has no timeout, and a try/catch cannot catch a hang. The footer says "rebuild pending," which is honest, and the build finishes.
Everything else fails. The test is simple: if the page's whole reason to exist is that data, an empty render is a lie, and a lie is what gets cached.
The same family of bug shows up without any cache involved. PostgREST caps a response at 1,000 rows no matter what limit you send. A category count computed in JavaScript over limit=20000 therefore summed the first 1,000 of 3,369 rows and published "1,000 listings" — a suspiciously round number that was precisely the cap. The sitemap did it too: 1,070 URLs emitted against 3,516 real ones, silently dropping the entire long tail. Both now read a Postgres view that does the aggregation server-side.
Truncation, a missing header, a caught exception. Every one of them returns a number rather than an error, and a number is believed.
This is how we built SkillWorks, a scored index of Claude Code skills and subagents: https://skillworks.kynth.studio
Top comments (0)