Skip to content
yceffort
PostsSeriesTagsAboutπŸ§ͺ Research
KO

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
β—† ESSAY
--min read
--year
ENtranslated

mailMail icongithubtwitter
yceffort
β€’
Β© 2026
β€’
https://yceffort.kr
BACK TO INDEX
β—† ESSAY
avatar
yceffort
2026-05-30 Β· 22 min read
reactreact-server-componentsmemoizationperformancefrontend
22min read
2026year
ENtranslated

React cache() Deep Dive: Request-Scoped Memoization, Read from the Source

Table of Contents

  • Introduction
  • The Short Version
  • cache() Is Neither 'use cache' Nor useMemo
  • The Dispatcher Decides Everything
  • The Cache Is a Tree Built from the Function and Its Arguments
    • Step 1: The Function Identity Is the Root
    • Step 2: Each Argument Descends One Level of the Tree
    • Step 3: Branch on the Final Node's Status
  • Where Does Per-Request Isolation Come From?
  • The Return Value Is Stored by Reference: preload and In-Flight Sharing
  • The Asymmetry of Error Caching: Sync throw vs Async Rejection
  • cacheSignal: A Signal That Severs When the Request Ends
  • So When Should You Use It?
  • Is There a Real Use for It in Next.js?
  • Wrapping Up
  • References

Introduction

cache() is an API that shipped as stable in React 191. Yet the moment you use it, your intuition keeps missing. You wrap a DB query and it still runs twice; you move the same code into a Client Component and nothing happens; you switch an argument to an object and the cache stops working entirely. The official docs list a whole bundle of rules β€” "Server Components only," "won't use the cache when called outside a component," "wrap it once at module scope," "the cache is invalidated on every request" β€” but they never tell you why.

You can use it fine if you memorize all the rules. But you don't have to. Every one of these rules is just a mechanical consequence of one small implementation. cache() in ReactCacheImpl.js is a single guard line that checks whether a dispatcher exists, a loop that descends a data structure one level per argument, and one line that stores the return value by reference β€” that's essentially all of it. Once you read these ~30 lines, the rules above answer their own "why" all at once.

So this post follows the implementation, not the usage, of cache(). Reading facebook/react's ReactCacheImpl.js2 and the Flight server's per-request cache store3 directly, we explain at the source level why it's RSC-only, why it doesn't work outside a component, why object arguments are dangerous, and why a failed fetch isn't retried.

One thing before we start. The subject of this post is the cache() function you import from react. It is a completely different thing from Next.js's 'use cache' directive. The names are similar enough to get confused often; that difference is covered separately in the 'use cache' directive deep dive. The boundary between the two is laid out clearly in the section right after The Short Version below.

Source analysis is based on facebook/react's v19.2.6 tag (the latest stable at the time of writing). main changes over time, so I cite a fixed tag, and every GitHub link in this post points to that tag. For reference, cache() has been in stable since React 19.0.0, and cacheSignal() (which appears later) since 19.2.0.

The Short Version

Before diving into the internals, here's the summary. Even if you read no further, this much is worth keeping.

  • cache() is memoization that works only within a single server request (render pass). When the request ends, the cache is discarded, and it is never shared across requests or users. It is not a persistent cache.
  • Whether memoization happens is decided by the existence of ReactSharedInternals.A (the AsyncDispatcher). If it's null (client, or outside a component), caching is skipped entirely and the function just runs. The two rules "RSC only" and "doesn't work outside a component" are literally this one line.
  • The cache is a tree built from the function reference and the arguments. Object/function arguments go into a WeakMap keyed by reference; primitives go into a Map keyed by value. That's why passing a fresh object every render is always a miss.
  • The return value is stored by reference, as-is. For an async function, the very same Promise object is cached, so awaits all over the tree share one in-flight request. This is the mechanism behind request deduplication and the preload pattern.
  • Error caching is asymmetric. Only a synchronous throw is stored as the ERRORED state; an async function's rejection is stored as the rejected promise (a value). Either way, there is no retry within the same request.

The body is the evidence for each item.

cache() Is Neither 'use cache' Nor useMemo

The first thing to clear up is the confusion of names. As server caching tools poured out all at once, cache(), 'use cache', unstable_cache, fetch memoization, and useMemo all blur together in your head. By results alone they look similar β€” "same input, same output, the second call is fast." But their scope and persistence are all different.

ToolRuntime / ScopeKeyPersistence
useMemo(fn, deps)Client, component instancereference equality of the deps arraynon-persistent (gone on rerender/unmount)
cache(fn)Server (RSC), single request renderfn reference + argument identitynon-persistent (gone when the request ends)
fetch() memoizationServer (RSC), single request renderURL + optionsnon-persistent (render-scoped)
fetch Data CacheServer, across requestsURL + options + tagspersistent (revalidate / tag)
unstable_cacheServer, across requestskeyParts + argumentspersistent
'use cache' directiveServer, across requestsbuildId + fnId + serialized argspersistent (host-dependent)
React Query / SWRClientquery keypersistent for the session

The key boundary is the bolded row. cache() belongs to the upper group (request-scoped, non-persistent), while 'use cache' / unstable_cache / Data Cache belong to the lower group (persistent caches that cross requests).

This difference is exactly why you must not mix them up. 'use cache' serializes the result into a key-value store and reuses it even after the request ends β€” even on the next request. That's why its arguments must be serializable and why it can't read cookies() directly. cache(), by contrast, holds the result as a raw JavaScript reference in memory and throws it away when the request ends. With no serialization, it can cache anything β€” a Promise, a class instance, whatever. But it can't take a single step outside the request.

In one sentence: 'use cache' is "a store that crosses requests," cache() is "a memo that lives for one request," and useMemo is "a memo that lives for one component." The names are similar; the lifetimes are completely different.

Now let's look at exactly how cache() "lives for one request," through the implementation. The starting point is the dispatcher.

The Dispatcher Decides Everything

Look at the first line of the function cache(fn) returns.

export function cache(fn) {
  return function () {
    const dispatcher = ReactSharedInternals.A
    if (!dispatcher) {
      // No dispatcher means we treat this as not cached.
      return fn.apply(null, arguments)
    }
    // ... actual caching starts here ...
  }
}

ReactSharedInternals is the shared communication channel between the React packages (react, react-dom, react-reconciler, react-server). These are separately published packages that can't import each other's internals directly, so react exposes one mutable object and the rest read and write it. Inside it, the "current dispatchers" that change during a render are held in single-letter slots β€” H is the Hooks dispatcher (the path useState and friends take), T is the transition config, and the one we care about, A, is the AsyncDispatcher4. As the slot comment says verbatim, it points to ReactCurrentCache, i.e. the "current cache."

This object is the one that used to be exposed under the notorious name __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED (literally, "or you will be fired"). In React 19, through a series of internal-cleanup PRs, that name became the less dramatic __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE, but the "don't touch this" intent is the same.

This dispatcher is populated only while React is rendering RSC on the server. The Flight server runtime plugs its own dispatcher into A when a render starts and clears it when the render ends. In every other situation β€” the client bundle, module-top-level code outside a component, ordinary event handlers β€” A is null.

So this one line, if (!dispatcher) return fn.apply(null, arguments), is the identity of the two pitfalls the official docs mention.

  • "cache is for use in Server Components only." There is no dispatcher on the client, so caching is skipped.
  • "Calling a memoized function outside of a component will not use the cache." Called outside a component, you're not in a render context, so there's no dispatcher and it skips again.

Neither is an error. It just quietly runs the function. That's what makes "why isn't the cache working?" tricky to debug β€” the behavior is fine, only the caching is missing.

The neutralization on the client is in fact blocked twice over. The React package splits into a server entry (ReactServer.js) and a client entry (ReactClient.js), and the ReactCacheClient.js the client entry imports looks like this5:

// ReactCacheClient.js (conceptually)
export const cache = disableClientCache ? noopCache : cacheImpl

disableClientCache in ReactFeatureFlags.js defaults to true. So the cache you import on the client is not the real implementation but noopCache, which simply calls fn and returns. The comment on noopCache is candid β€” "We intend to implement client caching in a future major release." And even if that flag were off and it connected to the real implementation, as we saw above, A is null on the client, so it falls through to fn.apply anyway.

By the way, using arguments and fn.apply(null, arguments) is also a deliberate choice. A source comment says it avoids rest parameters because they bloat the transpiled output. They treat this as a hot path and shave off every byte they can.

The Cache Is a Tree Built from the Function and Its Arguments

Once there's a dispatcher, the real caching begins. The cache's data structure is a tree that branches along the function and its arguments. The function itself is the root, and each argument extends one more branch below it. Calling with the same function and the same arguments lands you at the same branch tip (node), and the result hangs there.

First, the node. Each cache node looks like this.

const UNTERMINATED = 0 // no value yet
const TERMINATED = 1 // result stored
const ERRORED = 2 // error stored

function createCacheNode() {
  return {
    s: UNTERMINATED, // status: one of the three above
    v: undefined, // value: result or thrown error (meaning depends on s)
    o: null, // object cache: WeakMap for non-primitive args
    p: null, // primitive cache: Map for primitive args
  }
}

Four letters β€” s, v, o, p β€” are all there is. A single v is shared by result and error, and s distinguishes which. o and p are the two branching paths down to the next argument.

Now the full implementation. This is the part that follows the dispatcher guard we saw above.

export function cache(fn) {
  return function () {
    const dispatcher = ReactSharedInternals.A
    if (!dispatcher) {
      return fn.apply(null, arguments)
    }

    // 1) Get the per-request WeakMap, then find this fn's root node in it
    const fnMap = dispatcher.getCacheForType(createCacheRoot)
    let cacheNode = fnMap.get(fn)
    if (cacheNode === undefined) {
      cacheNode = createCacheNode()
      fnMap.set(fn, cacheNode)
    }

    // 2) Descend one tree level per argument
    for (let i = 0; i < arguments.length; i++) {
      const arg = arguments[i]
      if (
        typeof arg === 'function' ||
        (typeof arg === 'object' && arg !== null)
      ) {
        // Objects/functions: stored in a WeakMap keyed by reference
        let objectCache = cacheNode.o
        if (objectCache === null) cacheNode.o = objectCache = new WeakMap()
        let next = objectCache.get(arg)
        if (next === undefined) objectCache.set(arg, (next = createCacheNode()))
        cacheNode = next
      } else {
        // Primitives (including null): stored in a Map keyed by value
        let primitiveCache = cacheNode.p
        if (primitiveCache === null) cacheNode.p = primitiveCache = new Map()
        let next = primitiveCache.get(arg)
        if (next === undefined)
          primitiveCache.set(arg, (next = createCacheNode()))
        cacheNode = next
      }
    }

    // 3) Branch on the final node's status
    if (cacheNode.s === TERMINATED) return cacheNode.v
    if (cacheNode.s === ERRORED) throw cacheNode.v

    try {
      const result = fn.apply(null, arguments)
      cacheNode.s = TERMINATED
      cacheNode.v = result
      return result
    } catch (error) {
      cacheNode.s = ERRORED
      cacheNode.v = error
      throw error
    }
  }
}

The original uses Flow types and somewhat more verbose branches, but that's the whole behavior. Let's unpack the three steps.

Step 1: The Function Identity Is the Root

The fnMap returned by dispatcher.getCacheForType(createCacheRoot) is a WeakMap that lives only for this request (how it's per-request is the next section). The key of this WeakMap is the original fn reference.

This is where the most confusing rule in the docs dissolves.

"Calling cache with the same function multiple times will return different memoized functions that do not share the same cache."

Call cache(fn) twice and you get two different wrapper functions. But what gets used as the WeakMap key is not the wrapper β€” it's the original fn you passed in. So wrappers made by passing the same fn share the same root node no matter where in the tree they're called.

That said, people usually write it like this.

// a new wrapper on every render β€” often called an anti-pattern
export function Temperature({cityData}) {
  const getWeekReport = cache(calculateWeekReport)
  const report = getWeekReport(cityData)
  return <p>{report}</p>
}

The official docs flag this as an anti-pattern β€” since a new wrapper is created on every render, they say it "creates a new memoized function each time the component is rendered which doesn't allow for any cache sharing" (cache – React docs). But at the source level that explanation is wrong. As we saw, the root node's key is not the wrapper but the original fn (calculateWeekReport), and that fn is module-level, so it's the same every time. Even though a new wrapper is built and thrown away each render, at call time every one of them lands on the same node, via the same calculateWeekReport key in the same per-request WeakMap. So as long as you call it with the same argument, the cache really is shared. The same holds if another component wraps cache(calculateWeekReport) separately β€” contrary to the docs' wording, it does share.

What actually defeats the cache is not the wrapper's identity. It's (1) defining fn inline inside the component β€” e.g. cache((c) => …) β€” so the fn reference changes every render, or (2) passing a new object as the argument each time (see Step 2). The example above does neither, so it actually works.

So why still "wrap it once at module scope"? Even though sharing works, you pay for a throwaway wrapper each render, you lean on behavior the docs don't guarantee, and the moment someone inlines fn it breaks silently. The recommended form is to wrap it once in a dedicated module and import it.

// getWeekReport.js β€” defined once in a dedicated module
import {cache} from 'react'
export default cache(calculateWeekReport)

// usage: import the same memoized function to share it
import getWeekReport from './getWeekReport'

export function Temperature({cityData}) {
  const report = getWeekReport(cityData) // same cache wherever it's called in the tree
  return <p>{report}</p>
}

"Wrap it once at module scope" isn't a requirement that makes the cache work β€” it's recommended because it pins a stable fn reference and removes all three fragilities at once.

Step 2: Each Argument Descends One Level of the Tree

Once you've grabbed the root node, you walk the argument array and descend one level at a time. The branching condition is the heart of it.

if (typeof arg === 'function' || (typeof arg === 'object' && arg !== null)) {
  // objects/functions β†’ WeakMap (keyed by reference)
} else {
  // primitives β†’ Map (keyed by value)
}

Objects and functions go into the node's o (WeakMap) keyed by the reference itself. Strings, numbers, booleans, undefined, and null go into p (Map) keyed by value. The arg !== null guard blocks JavaScript's famous typeof null === 'object' trap, routing null to the primitive-side Map.

This branch explains the danger of object arguments. The docs phrase it as "shallow equality, compared with Object.is," but the actual lookup is just Map/WeakMap key equality. For object arguments it ultimately comes down to reference equality. That's why code like this quietly breaks.

// data.js
import {cache} from 'react'
export const getReport = cache((opts) => calc(opts.x, opts.y, opts.z))

// Cell.tsx (Server Component)
function Cell({x, y, z}) {
  // 🚩 a fresh object literal every render β†’ a different WeakMap key each time β†’ always a miss
  const report = getReport({x, y, z})
  return <pre>{report}</pre>
}

{x, y, z} is a new object every time even if the values are equal. To the WeakMap it's a different key each time, so the cache never hits. There are two fixes β€” unpack into primitives, or share a stable reference.

// (a) Pass primitives: the primitive Map keys by value, so equal values hit
export const getReport = cache((x, y, z) => calc(x, y, z))
function Cell({x, y, z}) {
  return <pre>{getReport(x, y, z)}</pre>
}

// (b) Share a stable reference: build the object once, pass it as-is
function App() {
  const vector = [10, 10, 10] // created once
  return (
    <>
      <Marker vector={vector} />
      <Marker vector={vector} /> {/* same reference β†’ hit */}
    </>
  )
}

The tree shape is worth noting too. For arguments (a, b, c) you descend three levels β€” root β†’ a node β†’ b node β†’ c node β€” and the result hangs on the final node. Since branches split in argument order, if the leading arguments match and the trailing ones differ, you share the path down to the intermediate node. Variadic arguments are handled naturally too β€” you just descend as many levels as there are arguments.

Step 3: Branch on the Final Node's Status

Once you've descended through the arguments, you look at the s of the node you arrived at.

  • TERMINATED (1): return the stored v as-is. Cache hit.
  • ERRORED (2): re-throw the stored error v. Errors are cached too.
  • otherwise (UNTERMINATED): run fn, store the result as TERMINATED (or a thrown error as ERRORED), and return.

Two details emerge here. One is how the return value is stored, and the other is the asymmetry of error caching. Each deserves its own section. But first, let's finish off why all of this is "per-request."

Where Does Per-Request Isolation Come From?

So far the word "request" hasn't appeared once in ReactCacheImpl.js. Neither the tree nor the nodes know about requests. Per-request isolation is provided not by the cache() implementation but by the dispatcher. Specifically, by getCacheForType.

What cache() calls was the single line dispatcher.getCacheForType(createCacheRoot). This dispatcher is the one the Flight server runtime plugged in, and getCacheForType reads the current Request's cache store. It looks roughly like this3.

// Flight server's getCacheForType (conceptually)
function getCacheForType(resourceType) {
  const cache = getCache() // the current Request's cache β€” a plain Map
  let entry = cache.get(resourceType)
  if (entry === undefined) {
    entry = resourceType() // first time: call the factory β†’ createCacheRoot() β†’ a new WeakMap
    cache.set(resourceType, entry)
  }
  return entry
}

And the Flight server creates a fresh store for every request (Request).

// Request instance in ReactFlightServer.js (excerpt)
this.cache = new Map()
this.cacheController = new AbortController()

You must not confuse the two layers of caching here.

  1. request.cache is a plain Map. Its key is the resourceType β€” that is, the createCacheRoot factory function cache() passed in.
  2. The value that Map returns is the WeakMap created by createCacheRoot(). That's the root of the tree inside cache().

Since every cache() call passes the same module-level createCacheRoot reference, within one request they all share a single WeakMap. Inside that WeakMap it branches again per fn, and then per argument the tree branches further. When the request changes, request.cache becomes a new Map, so createCacheRoot is called again and a fresh WeakMap is made. That's why the next request starts from scratch.

The dispatcher object itself is process-global, but the cache it returns belongs to "the current Request." So sharing the dispatcher does not mean sharing the cache. Two requests that arrive concurrently get their own Request β†’ their own cache Map β†’ isolated caches. That's why it's structurally impossible for user A's getUser('me') result to leak to user B.

How "the current Request" is found is the last piece of the puzzle. During synchronous execution it's resolved through a module-level currentRequest variable; across await boundaries that continue asynchronously, it's resolved through Node's async_hooks-based AsyncLocalStorage3. It's the mechanism that lets you see the same request's cache even after crossing async boundaries.

One more thing β€” there is no eviction inside a request. No TTL, no LRU, no size limit. A cached value stays until the request ends, and when the request ends it's discarded wholesale along with the Request. Object-keyed subtrees are WeakMaps, so an entry whose key object is no longer referenced anywhere can become eligible for GC, but that's a side effect, not an intended cache policy. cache() is a read-memoization primitive, not a managed cache store.

Exactly how Next.js App Router wires this up is hard to assert from the React source alone. But since App Router creates a Flight Request when it renders RSC, in practice you can treat the lifetime of cache()'s cache as aligned with "one server render of one route, one request." (This part is behavioral inference from React's per-request semantics; I did not verify Next's internal call sites directly.)

The Return Value Is Stored by Reference: preload and In-Flight Sharing

Look again at the cache-miss handling from step 3.

const result = fn.apply(null, arguments)
cacheNode.s = TERMINATED
cacheNode.v = result
return result

No await, no .then. It stores fn's return value by reference, as-is. If fn is an async function, result is a Promise object, and that same Promise is pinned to the node.

This seems trivial but produces powerful results. When a cached async function is called with the same arguments all over the tree, everyone shares one in-flight Promise created by the first call. The DB query fires only once, and the remaining awaits wait together for the same promise to resolve. fetch is deduplicated automatically, but DB and ORM queries are not β€” and cache() fills exactly that gap.

This property is the foundation of the preload pattern. Before the component that needs the data renders, you call it once in advance to kick off the work.

// user.js
import {cache} from 'react'
export const getUser = cache((id: string) => db.user.findById(id))
export function preload(id: string) {
  void getUser(id) // discard the result β€” the point is to start the work
}

// page.tsx
import {getUser, preload} from './user'

export default async function Page({id}: {id: string}) {
  preload(id) // fire the query before children render
  return <Profile id={id} />
}

// profile.tsx
async function Profile({id}: {id: string}) {
  const user = await getUser(id) // hits the same in-flight promise β€” no extra query
  return <h1>{user.name}</h1>
}

Because the promise fired by preload(id) is stored in the cache, when Profile later does await getUser(id) it receives the same promise. It's a common technique to shave one level off the waterfall (where the child fetch can't start until the parent fetch finishes).

Just don't forget that preload must be called inside a component. Call getUser('demo') at module top level and what happens? There's no dispatcher, so it just runs without caching, and when the component actually calls it the cache is empty and it runs again. The dispatcher guard from the earlier section applies here too.

The Asymmetry of Error Caching: Sync throw vs Async Rejection

Look again at the try/catch from step 3.

try {
  const result = fn.apply(null, arguments)
  cacheNode.s = TERMINATED
  cacheNode.v = result
  return result
} catch (error) {
  cacheNode.s = ERRORED
  cacheNode.v = error
  throw error
}

try/catch only catches a synchronous throw from fn.apply. When a sync function throws, the node becomes ERRORED, and calling again with the same arguments re-throws the stored error. As the source comment says β€” "We store the first error that's thrown and rethrow it."

But an async function does not throw synchronously. Even if an error occurs inside, an async function normally returns a "Promise that will reject later." So try/catch never fires, and the node becomes TERMINATED, not ERRORED. The v it stores is that to-be-rejected promise itself.

The result is this.

export const getUser = cache(async (id: string) => {
  const res = await fetch(`/api/user/${id}`)
  if (!res.ok) throw new Error('fetch failed') // not a synchronous throw β†’ a rejected promise
  return res.json()
})

If the first call rejects, every subsequent await getUser(id) within the same request receives the same rejected promise. fetch does not run again. In other words, within the same request, even a failure β€” once cached β€” is not retried.

To summarize.

CaseNode statusStored valueOn re-call in the same request
Sync function throwsERROREDthe thrown errorre-throw the stored error
Async function rejectsTERMINATEDthe to-be-rejected Promisereuse the same rejected promise (no retry)
Normal returnTERMINATEDresult (or a Promise)reuse the same value/promise

At the source level, the ERRORED state is indeed sync-throw-only. But in terms of observed behavior, an async failure is effectively cached too, via reuse of the rejected promise. That's also why the official docs simply say "cachedFn will also cache errors" without distinguishing sync from async.

There's one practical implication. Don't wrap a call that needs retrying within the same request in cache(). Fail once and you'll get the same failure back for the rest of that request. If you need retry logic, keep it outside cache(), or simply don't wrap calls whose failures must not be cached.

cacheSignal: A Signal That Severs When the Request Ends

ReactCacheImpl.js has one more small companion API next to cache. It came later, though β€” cache() has been around since React 19.0.0, but cacheSignal() is stable only since 19.2.0.

export function cacheSignal() {
  const dispatcher = ReactSharedInternals.A
  if (!dispatcher) return null
  return dispatcher.cacheSignal()
}

The pattern is identical to cache() β€” null if there's no dispatcher. If there is one, it returns that request's AbortSignal. Recall that the Request earlier was holding this.cacheController = new AbortController(); what cacheSignal() returns is that controller's signal.

The use is clear. Pass this signal into a cached async task and when the request ends (or is aborted) and the cache is discarded, that task is aborted along with it.

import {cache, cacheSignal} from 'react'

export const getUser = cache((id: string) =>
  fetch(`/api/user/${id}`, {signal: cacheSignal() ?? undefined}),
)

It's a guard against a background fetch outliving a severed request and holding onto resources. You've paired a cache that lives per-request with a signal that dies per-request.

There's another dispatcher with a similar name. The reconciler (Fiber/SSR) side also has a separate DefaultAsyncDispatcher with getCacheForType, but that's a different path that reads <Cache> boundary data from CacheContext to drive the use()/Suspense cache6. The cache() function is documented as scoped to Server Components, so it's better not to conflate this Fiber path with cache()'s behavior. They merely share the name getCacheForType; the path userland cache() actually takes is the Flight server dispatcher.

So When Should You Use It?

Now that we've seen the implementation, back to practical judgment. cache()'s place is narrower and clearer than you'd think.

Good places to use it. When several components within one RSC render need the same data. A layout and a page both look up the current user, or a sidebar and the main content read the same settings. Request-scoped deduplication of non-fetch DB/ORM queries, and doing an expensive computation just once across the whole tree. Reducing a waterfall with preload belongs here too. The common thread is all "sharing within one request, one render."

Places not to use it. A cache that needs to survive across requests. That's the job of 'use cache', unstable_cache, or the Data Cache β€” not cache(). cache() is empty-handed the moment the next request arrives. Client-side data caching isn't cache()'s domain either β€” there cache() is a no-op, so you use React Query or SWR. A call that needs retrying within the same request is, as we saw, also a poor fit.

Boil the decision down to one line. "Will I use this result again within this render?" If so, it's cache(). If you want to reuse it after the request ends, look at a different tool.

Is There a Real Use for It in Next.js?

If you work in the App Router, one thing nags at you: Next.js already dedupes fetch. Identical GET fetch calls (same URL and options) are automatically memoized within a render pass (React's request memoization). So you don't need to wrap fetch in cache() β€” the docs say so outright.

So where does cache() fit in Next? Everything that isn't fetch. The official docs recommend it directly β€” "If you are not using fetch (which is automatically memoized), and instead using an ORM or database directly, wrap your data access with the React cache function," complete with a Drizzle example. Prisma, Drizzle, and raw SQL are not auto-deduped the way fetch is. Assuming they are is the single most common mistake.

The flagship pattern is the authentication Data Access Layer (DAL). Wrap getCurrentUser() or verifySession() in cache(), and no matter how many times the layout, the page, leaf components, and Server Actions each call it, the DB query and session decryption happen only once per request. It's the canonical pattern pushed by Next's Authentication and Data Security guides β€” and since request-scoped dynamic values like cookies() can't be wrapped by 'use cache', cache() is the right tool here, not Cache Components.

One more misconception. cache() has not been superseded by 'use cache'. The two are orthogonal β€” cache() is request-scoped dedup, 'use cache' is cross-request persistence. What Next 16 replaces is not cache() but unstable_cache (β†’ 'use cache'). cache() remains valid across both the previous model and the Cache Components model, and the current docs still recommend it.

In short, cache() has a narrow but definite place in Next.js. A fetch-only app might never reach for it, but in a serious server tree tangled up with ORMs, sessions, and authorization, it's close to essential.

Wrapping Up

cache() is a ~30-line function. There's no magic inside.

  • No dispatcher means caching is skipped. β†’ RSC-only, and a no-op outside a component and on the client.
  • The cache is a tree built from the function reference and the arguments, and objects go into a WeakMap keyed by reference. β†’ Pass a fresh object every render and it always misses; wrap it once at module scope.
  • The cache store lives inside a Map the dispatcher creates fresh per request. β†’ Never shared across requests or users, and discarded when the request ends.
  • The return value is stored by reference, as-is. β†’ For async, the same promise is shared to dedupe the request, preload works, and a failure isn't retried within the same request.

Read one implementation instead of memorizing the docs' rules, and all of those rules turn into "they couldn't be otherwise." If cache() confused you, it wasn't because the API is complex β€” it was because you looked at the rules without seeing the one fact that this function is memoization tightly bound to the lifetime of a single request. The dispatcher, the tree, and the per-request Map are the three faces of that one fact.

References

  • cache – React official docs
  • facebook/react β€” ReactCacheImpl.js
  • facebook/react β€” ReactFlightServer.js
  • Deduplicating requests with React cache – Next.js docs
  • 'use cache' Directive Deep Dive: To the End of Cache Boundaries

Footnotes

  1. cache – React official docs β€” the source for all the official rules: cache()'s signature, scope (request-scoped, RSC-only), argument shallow equality (Object.is), error caching, the preload/data-snapshot-sharing use cases, "won't use the cache when called outside a component," and so on. cache() shipped as stable in React 19. ↩

  2. facebook/react β€” packages/react/src/ReactCacheImpl.js β€” the actual implementation of cache()/cacheSignal(). Status sentinels (UNTERMINATED=0/TERMINATED=1/ERRORED=2), the node shape {s, v, o, p}, the dispatcher guard, the WeakMap/Map tree traversal, and the error-caching logic. ↩

  3. facebook/react β€” packages/react-server/src/ReactFlightServer.js β€” the source of the per-request cache. The Request instance's this.cache = new Map(), this.cacheController = new AbortController(), getCache(request)/resolveRequest(), and current-request resolution via currentRequest/AsyncLocalStorage (requestStorage, async_hooks). getCacheForType itself lives in the sibling module flight/ReactFlightAsyncDispatcher.js's DefaultAsyncDispatcher, which reads this Map via getCache(request). ↩ ↩2 ↩3

  4. facebook/react β€” packages/react/src/ReactSharedInternalsClient.js β€” the ReactSharedInternals.A (AsyncDispatcher) slot, ReactCurrentCache per the comment. It defines the getCacheForType/cacheSignal contract. ↩

  5. facebook/react β€” packages/react/src/ReactCacheClient.js β€” the client entry's cache = disableClientCache ? noopCache : cacheImpl. The disableClientCache = true default in ReactFeatureFlags.js, and noopCache's "intend to implement client caching in a future major release" comment. ↩

  6. facebook/react β€” packages/react-reconciler/src/ReactFiberAsyncDispatcher.js β€” the reconciler's (Fiber/SSR) DefaultAsyncDispatcher. A separate path that reads <Cache> boundary data via readContext(CacheContext) to drive the use()/Suspense cache. Not to be confused with userland cache(). ↩

Related posts

  • β—† Kubernetes for Frontend Developers
    #nodejs#kubernetes#v8

    Why Is Your Node.js Pod That Size? Sizing Measured Firsthand, from NODE_OPTIONS to Pod Count

    I added one GC tuning flag to the same workload and peak RSS jumped from 201MB to 593MB, while the live data stayed the same. This post traces back through V8 New Space with direct measurements to show why that result is exactly what should happen, and lays out the three axes frontend developers can use to size a Node.js pod.

    2026-08-03Β·45 min read
  • #ai#essay#frontend

    Where Frontend Came From, and Where It Goes After Agents

    Why the layers piled up, why we returned to the server, and why the stack survives even after agents. And why the survival of a stack and the value of the people who know it are two separate things

    2026-07-22Β·19 min read
  • #frontend#bundle-analysis#performance

    The invisible cost in PR diffs: we are not reviewing the code our users receive

    The bundle costs that code review misses, and how to surface them in the PR.

    2026-05-03Β·22 min read
  • β—† Directive Deep Dive
    #react#nextjs#frontend

    'use cache' Directive Deep Dive: To the End of Cache Boundaries

    Build-time transformations, cache key serialization, ResumeDataCache, cacheHandler, and Cache Components - everything created by a single 'use cache' line

    2026-05-01Β·40 min read

Enjoyed this post? Subscribe via RSS to get new posts.

Subscribe via RSS β†’

yceffort β€” frontend engineer. For talks, consulting, or writing inquiries, reach out here.

← Back to the blogν•œκ΅­μ–΄λ‘œ 읽기 β†’