Don't Cache Your Failures
Caching is one of the easiest ways to make a page feel fast. Wrap a slow call in unstable_cache, set a revalidation time, and every request after the first one is served instantly.
But a cache stores whatever you give it. Including your failures.
So how do you make sure only good results end up in the cache?
Step one is understanding what unstable_cache actually stores.
What gets cached
Here is a pattern I've seen in many codebases, including my own. A function fetches data from a CMS, and if anything goes wrong, it returns null so the page can render an empty state instead of crashing.
import { unstable_cache } from 'next/cache'
export const getPosts = unstable_cache(
async () => {
try {
return await cms.fetch<Post[]>(POSTS_QUERY)
} catch (error) {
logger.error(error)
return null
}
},
['posts'],
{ revalidate: 3600 }
)A reasonable-looking function that caches its own failures.
It looks defensive. The page never crashes, and the error is logged. The problem is in what happens next.
If the CMS is unavailable for even a moment, the catch block returns null. From the cache's point of view, that is a perfectly valid result. It stores it and serves it to every request until the next revalidation.
- The CMS fails for a few hundred milliseconds.
getPosts()catches the error and returnsnull.unstable_cachestoresnullfor the next hour.- The CMS recovers, but every visitor still sees an empty page.
A failure that lasted a fraction of a second becomes an outage that lasts the full revalidation window.
Errors are the exception
unstable_cache stores whatever your function resolves to. null, undefined, and empty arrays are all valid results, and all of them get cached.
A thrown error is different. When the function throws, there is no result to store, so nothing is written to the cache. And when a background revalidation fails, Next.js keeps serving the last successful value instead of replacing it.
// Returns null → null is cached until the next revalidation
// Throws → nothing is cached, the last good value is keptThe same failure, handled in two different places.
This means the try/catch is not the problem. Its position is.
Keeping failures out of the cache
The fix is to move error handling outside of the cached function. I split it into three parts:
import { unstable_cache } from 'next/cache'
// Caches the result. Only successful responses ever reach it.
const getPostsCached = unstable_cache(
async () => cms.fetch<Post[]>(POSTS_QUERY), // no try/catch: failures throw
['posts'],
{ revalidate: 3600 }
)
// Handles errors outside the cache, for the current request only.
export async function getPosts() {
try {
return await getPostsCached()
} catch (error) {
logger.error(error)
return null
}
}The page still receives null on failure, but the cache never does.
The page behaves exactly as before: it receives null when something goes wrong. The difference is that null is now scoped to a single request, and the next one gets a fresh attempt.
The same applies to use cache
In Next.js 16, unstable_cache has been replaced by the 'use cache' directive. The API is different, but the rule is the same: a returned null is a successful result and will be cached for the entire cache lifetime.
async function getPostsCached() {
'use cache'
return cms.fetch<Post[]>(POSTS_QUERY)
}
export async function getPosts() {
try {
return await getPostsCached()
} catch {
return null
}
}Same structure with the newer directive.
Caching with intent
The goal of a cache is to make good responses fast, not to make bad ones last longer. Let the cached function fail loudly, and decide how to handle that failure one layer above it.
Sometimes the best thing a cache can do is store nothing at all.