← All articles

[ Blog ]

July 16, 2026

7 min read

Category: Insights

Next.js unstable_cache Caching null Forever? The Fix

A one-second DB blip and your Next.js page is empty forever? unstable_cache cached the null your catch returned. The two-line fix that heals itself.

Next.jsCachingunstable_cacheApp RouterPrismaNeon
Next.js unstable_cache Caching null Forever? The Fix

A page on a profile project — a self-hosted CMS on Next.js with Neon Postgres behind it — rendered empty on production and stayed empty. Not a 500, not an error boundary: the page shipped fine with the content section just missing, indefinitely. Local dev was fine, the database was fine, the row was sitting right there in the table. If you've hit Next.js unstable_cache caching null forever — a page or whole collection that goes blank and never comes back until someone re-saves it in the CMS — you're looking at the same bug. The root cause is one defensive line in the wrong place, and the fix moves it two lines up and out.

The symptom: an empty page that never heals

The shape of this bug is what makes it nasty:

  • Production renders the page's empty state (or an empty collection) with no error anywhere — logs clean, build green.
  • It persists for hours or days. Redeploys don't reliably fix it.
  • It heals only when an editor happens to re-save that exact item in the CMS — which looks like magic and gets misdiagnosed as "the CMS didn't publish properly."
  • Locally you can't reproduce it at all.

Nothing about that screams "caching." It screams "data bug," so you audit the query, the CMS, the row — all fine. The problem is when the query failed, once, and what your code returned in that moment.

Root cause: unstable_cache caches your fallback, not your error

Here's the reader, in the exact shape I found it. It looks responsible — defensive, even:

// The reader swallows the error and returns a fallback…
async function getPage(slug) {
  try { return await prisma.page.findUnique({ where: { slug } }) }
  catch { return null }              // ← transient Neon cold-start timeout lands here
}
// …and THAT null is what gets cached — with revalidate: false, i.e. forever.
const getCachedPage = (slug) =>
  unstable_cache(() => getPage(slug), ['cms-page', slug], {
    tags: cacheTags.page(slug),
    revalidate: false,               // never re-runs on its own
  })()

unstable_cache caches the function's return value. It has no idea null means "the database timed out" rather than "this page doesn't exist." A swallowed error returns a perfectly cacheable null (or [] for a collection reader), so a one-off DB blip gets promoted to a permanent empty page.

And revalidate: false is what makes it permanent: the cached function never re-runs on its own. The only thing that clears the entry is a revalidateTag on that exact tag — which, in a CMS-backed site, fires when an editor mutates that exact item. That's why re-saving the page in the CMS "fixes" it. The editor isn't republishing content; they're unknowingly flushing a poisoned cache entry.

Where the catch sitsWhat a transient DB error becomes
Inside the cached functionnull/[] cached under the tag — empty page until the next mutation on that tag
Outside the cache wrapperFunction throws → nothing cached → next request retries and heals itself

Serverless Postgres makes this likely, not theoretical

On a traditional always-on Postgres, "the query randomly timed out once" is rare enough that this bug could hide for years. On serverless Postgres it's a design feature: Neon scales to zero, and a cold start on the first query after idle can time out. So the failure sequence is mundane: site idles → compute suspends → a crawler hits a quiet page → the wake-up query times out → catch returns nullunstable_cache freezes it. The quietest pages — the ones least likely to get re-saved by an editor — are exactly the ones most likely to be hit cold and poisoned longest.

I've written before about the adjacent failure on the same substrate: why a Neon database hits its compute limit on a low-traffic site. Same cold-start root, opposite symptom — that one burns quota, this one freezes emptiness.

The fix: move the catch outside the cache wrapper

The property you want is: if the cached function throws, nothing is cached, and the next request retries. So the inner reader must be allowed to throw, and the fallback moves to the outside of the wrapper:

const queryPage = (slug) => prisma.page.findUnique({ where: { slug } })  // throws
const getCachedPage = (slug) =>
  unstable_cache(() => queryPage(slug), ['cms-page', slug], {
    tags: cacheTags.page(slug), revalidate: false,
  })().catch(() => null)             // ← same fallback for callers, nothing poisoned

Callers see the identical API — they still get page | null and render the same empty state on a bad request. The only thing that changed is what the cache is allowed to remember. A transient error now costs exactly one request's worth of fallback UI instead of an indefinitely empty page.

That's the whole fix. Two lines of restructuring, no new dependencies, no retry library.

Audit every reader — this bug wears different clothes

The instance you found is rarely the only one. With revalidate: false, a cached function's error handling is its cache-poisoning policy: anything it returns instead of throwing gets frozen in. getPage → catch return null and getCollection → catch return [] are the same bug in different clothes, and so is any ?? defaultValue that can absorb a failure inside the wrapper.

A quick sweep that has caught every instance for me:

# every unstable_cache call site…
grep -rn "unstable_cache" src/
# …then check each wrapped function for catch/fallback INSIDE the wrapper
grep -rn -B2 -A6 "catch" src/lib/readers/

For each hit, ask one question: if the DB dies mid-request, does the function passed to unstable_cache throw, or return? If it returns, that return value is one bad request away from becoming permanent content.

This is the third "silently wrong default" I've hit in the same data layer — alongside Next.js not caching CMS fetches the way you assume. The pattern across all of them: nothing errors, the platform does exactly what it documented, and the damage only shows in production traffic patterns you don't have locally.

FAQ

Why does my Next.js page come back after re-saving in the CMS?

Because the re-save fires revalidateTag on that item's tag, which flushes the poisoned cache entry, and the next request re-runs the query successfully. The CMS save isn't fixing content — it's accidentally clearing a cache that stored a null from a transient DB error.

Is it ever OK to return null inside an unstable_cache function?

Yes — when null is a true answer, e.g. the row genuinely doesn't exist and you want that 404 cached. The rule is about failure paths: a real "not found" may return null; an error (timeout, connection refused) must throw so nothing gets cached. Don't let one catch conflate the two.

Does revalidate: 3600 instead of false fix this?

It bounds it. A poisoned entry heals at the next revalidation window, so the empty page lasts at most an hour instead of forever. That's damage control, not a fix — you'd still serve a blank page for up to an hour after a one-second blip. Catch outside the wrapper and you get both: long-lived cache and self-healing errors.

Does this apply to fetch caching and "use cache" too?

The principle generalizes: any cache that stores return values will freeze whatever your code returns during a failure, so keep error-swallowing outside the cached unit regardless of API. Verify the specific error semantics of each API against the Next.js caching docs rather than assuming — but "throw on failure inside, fall back outside" is the safe shape everywhere.


One line to take away: with revalidate: false, whatever your cached function returns during a failure becomes permanent content — so let it throw, and catch outside the wrapper.

I build and debug CMS data layers like this one end-to-end — caching, Postgres, and the resilience plumbing — as part of full-stack site work.

Related posts