โ ESSAY
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 fromreact. 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'sv19.2.6tag (the latest stable at the time of writing).mainchanges 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, andcacheSignal()(which appears later) since 19.2.0.
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.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.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.awaits all over the tree share one in-flight request. This is the mechanism behind request deduplication and the preload pattern.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.
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.
| Tool | Runtime / Scope | Key | Persistence |
|---|---|---|---|
useMemo(fn, deps) | Client, component instance | reference equality of the deps array | non-persistent (gone on rerender/unmount) |
cache(fn) | Server (RSC), single request render | fn reference + argument identity | non-persistent (gone when the request ends) |
fetch() memoization | Server (RSC), single request render | URL + options | non-persistent (render-scoped) |
| fetch Data Cache | Server, across requests | URL + options + tags | persistent (revalidate / tag) |
unstable_cache | Server, across requests | keyParts + arguments | persistent |
'use cache' directive | Server, across requests | buildId + fnId + serialized args | persistent (host-dependent) |
| React Query / SWR | Client | query key | persistent 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," anduseMemois "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.
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 that name was changed to 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.
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
argumentsandfn.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.
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.
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.
The problem is that people usually write it like this.
// ๐ฉ a new wrapper with its own empty cache on every render
export function Temperature({cityData}) {
const getWeekReport = cache(calculateWeekReport)
const report = getWeekReport(cityData)
return <p>{report}</p>
}
Now, on every render and every instance, cache(calculateWeekReport) is called anew and a new wrapper is created. The wrapper itself differs each time; the root key is still calculateWeekReport, so you'd think the cache node is shared โ but because the call site builds a new wrapper, calls it once, and throws it away, memoization is meaningless here. It's also impossible to call the same memoized function from a different component.
The right answer is to wrap it once at module scope 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 superstition โ it follows directly from the wrapper's identity and the call structure.
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.
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.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."
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.
request.cache is a plain Map. Its key is the resourceType โ that is, the createCacheRoot factory function cache() passed in.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
cacheMap โ isolated caches. That's why it's structurally impossible for user A'sgetUser('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.)
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
preloadmust be called inside a component. CallgetUser('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.
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.
| Case | Node status | Stored value | On re-call in the same request |
|---|---|---|---|
Sync function throws | ERRORED | the thrown error | re-throw the stored error |
| Async function rejects | TERMINATED | the to-be-rejected Promise | reuse the same rejected promise (no retry) |
| Normal return | TERMINATED | result (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.
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
DefaultAsyncDispatcherwithgetCacheForType, but that's a different path that reads<Cache>boundary data fromCacheContextto drive theuse()/Suspense cache6. Thecache()function is documented as scoped to Server Components, so it's better not to conflate this Fiber path withcache()'s behavior. They merely share the namegetCacheForType; the path userlandcache()actually takes is the Flight server dispatcher.
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.
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.
cache() is a ~30-line function. There's no magic inside.
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.
facebook/react โ ReactCacheImpl.jsfacebook/react โ ReactFlightServer.js'use cache' Directive Deep Dive: To the End of Cache Boundariescache โ 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. โฉ
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. โฉ
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
facebook/react โ packages/react/src/ReactSharedInternalsClient.js โ the ReactSharedInternals.A (AsyncDispatcher) slot, ReactCurrentCache per the comment. It defines the getCacheForType/cacheSignal contract. โฉ
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. โฉ
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(). โฉ