
Next.js unstable_cache
If you've spent any time in a Next.js codebase written before the App Router's newer caching model matured, you've almost certainly run into unstable_cache. It's the function that lets you wrap an expensive database query, a slow third-party API call, or any other asynchronous operation in a cache that persists across requests, and even across deployments. It's been part of Next.js since version 14, it's used in production by a huge number of apps, and yet its name still starts with unstable_. That prefix confuses almost everyone who encounters it for the first time, and it's worth untangling before you decide whether to reach for it in new code.
This article is a deep, standalone reference for unstable_cache itself: its exact signature, what each parameter actually does, the gotchas that trip people up, and — importantly — where it sits now that Next.js 16 has shipped a newer, officially blessed caching primitive.
The one thing you need to know upfront
As of Next.js 16, unstable_cache has been superseded by the use cache directive. The official recommendation is to opt into Cache Components and replace unstable_cache calls with use cache. That doesn't mean unstable_cache stops working today — it's still shipped, still functional, and still the only option if your project hasn't adopted Cache Components — but it does mean you're looking at a function in maintenance mode rather than one under active development. If you're starting a brand-new project on Next.js 16 with Cache Components enabled, skip straight to use cache and treat the rest of this article as background for when you inherit an older codebase, or need to understand what a unstable_cache call you're staring at is actually doing.
What problem it solves
Without any caching layer, every time a Server Component or Route Handler runs, any data-fetching code inside it runs fresh. For a component that reads from a URL via the built-in fetch(), Next.js already gives you request memoization and response caching for free. But the moment you're not using fetch() — you're querying a database directly with an ORM, calling a gRPC service, reading from a file, or doing any other asynchronous work that doesn't go through fetch() — none of that built-in caching applies. unstable_cache exists to close that gap: it lets you wrap any async function, regardless of what it does internally, and get the same kind of persistent, cross-request caching that fetch() gets natively.
import { getUser } from "./data";
import { unstable_cache } from "next/cache";
const getCachedUser = unstable_cache(
async (id) => getUser(id),
["my-app-user"],
);
export default async function Component({ userID }) {
const user = await getCachedUser(userID);
// ...
}
That's the whole shape of it: you pass in the function you want cached, an array of extra key parts, and an options object, and you get back a new function with the same signature that transparently caches its result.
The full signature
const data = unstable_cache(fetchData, keyParts, options)();
fetchData — an asynchronous function that returns the data you want cached. It must return a Promise. This is the actual work being memoized: a database call, a computation, a call out to another service, whatever you need cached.
keyParts — an array of additional values that get folded into the cache key. This is the parameter that trips people up most often, so it's worth being precise about it. By default, unstable_cache derives a cache key from the arguments passed to the returned function and a stringified version of the fetchData function itself. keyParts is optional in the common case where all your variability comes through function arguments. Where it becomes mandatory is when your fetchData function closes over an external variable — reads something from outer scope rather than receiving it as a parameter. Next.js can't see inside a closure to know that an outer variable changed, so if you don't pass that variable through as either an argument or a keyParts entry, you'll get stale results served from a cache key that never changes even though the underlying data did.
options — an object controlling cache behavior, with two properties:
tags— an array of string tags you can later invalidate withrevalidateTag(). Note the specific wording in the docs here: Next.js does not usetagsto uniquely identify the cached function — that's whatkeyPartsand the function's own arguments are for.tagsis purely an invalidation handle.revalidate— the number of seconds after which the cached entry is considered stale and will be revalidated. Omit it, or explicitly passfalse, and the entry is cached indefinitely, until something callsrevalidateTag()orrevalidatePath()against it.
What it returns
Calling unstable_cache(fetchData, keyParts, options) doesn't run anything immediately — it returns a new function. Calling that function is what triggers the caching behavior: on a cache miss, it invokes your original fetchData, stores the result, and returns it; on a cache hit, it skips fetchData entirely and returns the previously stored value. This two-step shape (get a function back, then call it) is exactly why you'll usually see it defined once, near the top of a module or inside a component body, and then invoked separately.
A complete example with real options
import { unstable_cache } from "next/cache";
export default async function Page({
params,
}: {
params: Promise<{ userId: string }>;
}) {
const { userId } = await params;
const getCachedUser = unstable_cache(
async () => {
return { id: userId };
},
[userId], // add the user ID to the cache key
{
tags: ["users"],
revalidate: 60,
},
);
const user = await getCachedUser();
// ...
}
Notice that userId shows up twice: once as a closure variable inside fetchData, and again inside keyParts. That's not redundant — it's the whole point. Because fetchData here is a closure that reads userId from outer scope rather than receiving it as a parameter, Next.js has no way to know that a different userId should produce a different cache entry unless you tell it explicitly via keyParts. Leave that array empty here and every user on your site would silently get served the first user's cached data.
Where people get burned
Forgetting a closed-over variable in keyParts. This is, by a wide margin, the most common mistake. If your cached function reads anything from outside its own argument list — a variable from a parent scope, a value destructured earlier in the component, a context value — and you don't add it to keyParts, you'll get a cache that silently serves the wrong data to the wrong request. The symptom is almost always "user A is somehow seeing user B's data," and it's maddening to debug precisely because everything looks correct until you notice the missing key part.
Trying to read headers() or cookies() inside the cached function. The docs are explicit that accessing uncached, per-request data sources like headers or cookies from inside a unstable_cache-wrapped function isn't supported — request-scoped data and a cross-request cache are fundamentally at odds with each other. If your cached function needs something from the incoming request, read it outside the cached function and pass it in as an argument (which then correctly becomes part of the cache key through the normal argument mechanism).
Assuming tags alone makes the cache unique per call. Tags are for invalidation, not identity. Two calls with the same fetchData and the same arguments but different tags arrays will still share a cache entry if you haven't varied the actual arguments or keyParts. If you need distinct caching and distinct invalidation groups, you need both a unique key (via arguments/keyParts) and the appropriate tags.
Not revalidating at all and being surprised data never updates. If you omit revalidate and never call revalidateTag()/revalidatePath() against your tags, the cached value really does persist indefinitely — across requests and across deployments, per the docs. That's sometimes exactly what you want (build-time-stable reference data), and sometimes a bug waiting to be discovered in production. Be deliberate about which one you intend.
unstable_cache vs. use cache
Since Next.js 16 introduced use cache as the intended replacement, it's worth being concrete about how the two actually differ, not just that one is "newer."
unstable_cache | use cache | |
|---|---|---|
| Mechanism | Wraps a specific function call | A directive applied to a function, component, or file |
| Cache key | Function arguments + keyParts + stringified function | Automatically derived from arguments and closure |
| Invalidation | tags option + revalidateTag()/revalidatePath() | cacheTag()/cacheLife() calls inside the cached scope, plus revalidateTag()/updateTag() |
| Composability | You explicitly call the wrapped function | Integrates with Suspense/streaming and Partial Prerendering automatically |
| Status | Stable in practice, but frozen/deprecated as of v16 | Actively developed, the recommended path forward |
The practical upshot: if you're maintaining an existing app that already leans on unstable_cache and hasn't adopted Cache Components, there's no urgent need to rip it out — it still works exactly as documented. But new caching code, and any code you're touching anyway during a refactor, should go through use cache instead, both because that's where future improvements will land and because it composes more cleanly with streaming and Partial Prerendering than a manually-wrapped function call does.
When it's still the right tool
Despite the deprecation notice, there are a couple of situations where unstable_cache remains genuinely reasonable to reach for:
- You're on a version of Next.js, or a project configuration, that hasn't adopted Cache Components yet, and rewriting your whole data layer isn't in scope right now.
- You need caching behavior for a piece of logic outside the App Router's component tree entirely — for instance, in a script or a background job that imports from
next/cachebut doesn't run inside a request lifecycle in the wayuse cacheassumes.
Outside of those cases, treat use cache as the default, and think of unstable_cache the way you'd think of any other "it works, it's just not where new investment is going" API.
Key Takeaways
| Aspect | Behavior |
|---|---|
| Purpose | Cache the result of any async function across requests and deployments |
| Required key inputs | Function arguments, plus anything closed-over must go in keyParts |
| Invalidation | tags option paired with revalidateTag() / revalidatePath() |
| Default lifetime | Indefinite if revalidate is omitted or false |
| Not supported inside | Reading headers() / cookies() directly — pass them in as arguments instead |
| Current status | Deprecated in favor of use cache as of Next.js 16, but still functional |
| Introduced | Next.js v14.0.0 |
unstable_cache was, for a long time, the only way to bring fetch()-style caching to non-fetch() data sources in the App Router, and it did that job well. If you're working in a codebase that already uses it, understanding its exact key-derivation rules will save you from the single most common bug people hit with it — silently stale or cross-contaminated cache entries caused by an unlisted closure variable. And if you're starting something new on a version of Next.js with Cache Components available, use cache is where you should be putting your effort instead.


