I shipped a directory page that told visitors there were zero listings while rendering a grid of 48 of them. Fixed it. Then, about nine hours later, shipped a different bug with the exact same symptom, in the exact same function, for a completely unrelated reason.
The product is SkillWorks, a scored directory of Claude Code skills, subagents, plugins and marketplace repos I'm building under Kynth. It reads from Supabase over PostgREST, and it needs a lot of row counts: total listings, counts per kind, how many are broken, how many have tracked installs. Counting rows without pulling them is the one thing PostgREST makes genuinely cheap — ask for Prefer: count=exact with Range: 0-0 and the total comes back in the Content-Range response header. No rows in the body at all.
That header is where both bugs live.
The first zero: the header didn't survive the cache
Original version, in src/lib/db.ts:
async function countRows(table: string, query: string, col = 'id'): Promise<number> {
const res = await fetch(`${URL}/rest/v1/${table}?${query}&select=${col}&limit=1`, {
headers: { apikey: KEY, Authorization: `Bearer ${KEY}`, Prefer: 'count=exact', Range: '0-0' },
next: { revalidate: REVALIDATE },
});
const range = res.headers.get('content-range');
return range ? Number(range.split('/')[1]) || 0 : 0;
}
Next's fetch cache stores the response body. On a cache hit it hands you back a Response assembled with a synthetic header set, and content-range is not in it. So every cache hit read null and fell through to 0.
What kept this hidden for a while is that statically prerendered routes were fine. Their one fetch happens at build time and is always a miss, so the real header is right there. Only routes rendering at runtime hit the cache, and those were the ones shipping "0 indexed" next to a full grid. /marketplaces read "None of the 0 marketplaces".
The fix is to stop caching a thing whose value lives outside the cached payload. Make the fetch cache: 'no-store' and wrap the whole function in unstable_cache, which memoises the return value instead. Still one request an hour, but now what's stored is a parsed number, which survives being stored.
The second zero: a failed read stored as an answer
That was August 1st, early afternoon. That evening Supabase had an outage, and I watched /skills render "Search all 0 listings" above cards that looked completely normal.
The cards were fine because they come from Next's fetch cache and it still had them. The counts came through the new uncached path, hit a dead index, and returned 0 — which is the last line of the function above, doing exactly what it was written to do. And unstable_cache dutifully stored that 0 for the full REVALIDATE window, which is 3600 seconds.
That's the part that actually bothered me. The outage was maybe twenty minutes. The wrong number would have outlived it by forty. The database could be answering perfectly and the site would still be advertising zero listings, with no error, no degraded state, nothing to look at. A directory that confidently reports emptiness is worse off than one that's visibly broken, because nobody goes looking.
The || 0 was defensive code from the first bug that quietly became the second one. Returning a fallback and memoising a fallback are different operations, and one function was doing both.
Throw inside the cache, degrade outside it
const countRows = async (table: string, query: string, col = 'id'): Promise<number> => {
try {
return await unstable_cache(
async () => {
const res = await fetch(/* … */, { cache: 'no-store' });
const range = res.headers.get('content-range');
// THROW rather than return 0 when the index cannot be reached.
// `unstable_cache` stores whatever the function RETURNS, for a full hour.
if (!res.ok || !range) throw new Error(`count ${table}: ${res.status}`);
const total = Number(range.split('/')[1]);
if (!Number.isFinite(total)) throw new Error(`count ${table}: unparseable content-range`);
return total;
},
['count', table, query, col],
{ revalidate: REVALIDATE },
)();
} catch {
// 0 is still what the caller gets — a directory page that 500s over a stat line is worse
// than one that under-reports for a few seconds. The difference is this 0 is NOT cached.
return 0;
}
};
The caller still gets 0. A browse page shouldn't throw a 500 because a stat line in a search placeholder couldn't be computed. But the fallback now lives outside the memo, so nothing gets written, the next request re-reads, and the wrong number lasts exactly as long as the outage does and not one second longer.
The same weekend, in a different product, I hit the same shape from the other direction. ListRun has pages where the row is the page — a directory detail, a run, a paid receipt — and they all called a helper that returned null on any failed read, then called notFound(). Which means a slow database told a buyer their run does not exist, and on a cached render it baked that 404 in. The fix there was apiRequired in src/lib/site.ts: return null only on an actual 404 from the route, throw on anything else, so notFound() is always a statement about the row.
Both are the same mistake. A read that fails and a read that legitimately returns nothing produce the same value, and then something downstream — a cache, a router — treats that value as established fact and keeps it around. The failure gets laundered into data on the way out.
I've stopped trusting any fallback that sits inside a memo boundary. If a function can be wrong and can be stored, those two facts need to be separated by a throw.

Top comments (0)