skip to content

Suspense data reading is usually described as requiring a cache keyed by request identity. Beyond memoizing a resolved value, what must such a cache guarantee for use() to behave correctly in React 19?

level: seniorimportance: should knowfreq 36%

answer

  1. cache the promise, not just the value
  2. same key returns the identical object
  3. store the entry while still pending
  4. rejection needs a home too
  5. module scope leaks across server requests

basics

~20 s

It must return the identical promise for a given key on every render, dedupe requests already in flight, remember rejections as well as results, and be scoped per request on the server so data cannot leak between users.

solid answer

~50 s

Four guarantees. First, **stable identity**: the same key must yield the same promise object across renders and across every component that asks, or retries create new work and loop. Second, **in-flight deduping**: the entry has to be written when the request starts, not when it finishes, so concurrent readers share one request. Third, **settled-state memory**: it must record rejection as well as fulfilment — if a failed entry is dropped, the retry re-issues the request and you get a loop instead of an error surfacing to an error boundary; if it is kept forever, you need an explicit way to retry. Fourth, **correct scope and lifetime**: a module-level `Map` is fine per browser tab but is a cross-user data leak on a server, which is why React exposes `cache()` for per-request memoization in Server Components. Then you still owe eviction and invalidation policy, which is the part React does not define.

code

javascript · 14 lines
javascript
const store = new Map();

export function read(key, load) {
  if (!store.has(key)) {
    // entry written while the promise is still pending: concurrent
    // readers of the same key share one request
    store.set(key, load(key));
  }
  return store.get(key);
}

export function invalidate(key) {
  store.delete(key);
}

go deeper

for a junior

Know that Suspense reads go through a cache and that the cache holds the promise itself, keyed by what identifies the request, rather than only the finished data.

for a middle

Explain why the entry must be written while the promise is still pending, and why the same key must return the identical promise object on every render attempt.

for a senior

Show the operational thinking: rejection handling that surfaces an error instead of looping, an explicit invalidation path, eviction so the cache does not grow forever, and per-request scope on the server instead of a process-wide Map.

for a principal

Decide who owns this cache across the codebase and where its boundaries lie — key schema, invalidation triggers after mutations, server-to-client handoff — and justify building it versus adopting a library that already answers those questions.

## Why identity, not value The naive reading of "cache" is "store the resolved data". That is not sufficient for Suspense, because the component reads the resource *while it is still pending*. The unit that must be cached is the **promise**, keyed by whatever identifies the request — endpoint plus parameters, an entity type plus id, a serialized query. If the same key can produce two different promise objects, a retry can suspend on a promise it has never seen and the render never converges. So the first guarantee is: `resource(key) === resource(key)` for as long as the entry lives. ## Write the entry when the request starts A cache that stores results on completion still lets two components that mount together fire two requests, because neither sees an entry yet. The entry must be created **synchronously at request start**, holding the pending promise: ```js function read(key, load) { let entry = store.get(key); if (!entry) { entry = load(key); // stored while still pending store.set(key, entry); } return entry; } ``` This is what "deduping in-flight requests" means concretely, and it is why the ordering of those three lines matters. ## Record the failure, not only the success Rejection is the case that separates a toy cache from a usable one. Two wrong behaviours: - **Delete the entry on rejection.** The retry finds nothing, issues the request again, suspends again — the same refetch loop, now triggered by an outage rather than by bad code, and the error boundary never sees a stable failure. - **Cache the rejection forever with no escape.** Now a transient network blip poisons that key for the lifetime of the page and the only recovery is a full reload. The workable shape keeps the rejected entry so the retry can throw it to an error boundary, and pairs that with an explicit way to clear the key — a reset handler on the error boundary, or an invalidate call — so recovery is a deliberate action rather than an accidental loop. Note that React itself supplies no retry policy; if you want backoff, you write it. ## Scope and lifetime On the client, a module-level `Map` is scoped to the tab, which is usually what you want, though it grows without bound unless you evict. On the server the same `Map` is scoped to the **process**, shared across every concurrent request from every user. Caching a per-user response there is a data-leak bug, not a performance win. React's answer for Server Components is `cache(fn)` from `react`, which memoizes a function's results for the duration of a single server request — the right scope by construction. Multiple Server Components can call the same cached loader with the same arguments and share one call, and nothing survives into the next user's request. A related consequence: data fetched on the server has to reach the client cache somehow, or the browser re-requests everything after hydration. That transfer is exactly the kind of plumbing a framework or data library owns. ## Keys are a design decision The key defines the identity of the request, so it determines what dedupes and what invalidates together. Keys that are too coarse (`'user'`) collide across entities; keys that are too fine — anything containing a fresh object, a timestamp, or a function identity — never hit. Serialising the parameters into the key deterministically (stable order, no `Date.now()`) is what makes the cache work at all. ## What is left over Stability, deduping, failure memory and scope make `use()` *correct*. They do not make an application's data layer *complete*: staleness, background revalidation, mutation invalidation, pagination, optimistic updates and garbage collection are all still unowned. That gap is deliberate — React defines the render-time protocol and leaves the caching policy to the layer above it.

  • Why is a module-level cache acceptable in the browser but a bug on the server?
    In the browser the module instance is scoped to one tab and one user, so sharing entries is exactly the intent. On the server the same module lives for the whole process and serves every concurrent request, so a per-user response cached there can be handed to a different user. Server-side memoization needs per-request scope, which is what React's cache() provides in Server Components.
  • What goes wrong if the cache deletes an entry as soon as its promise rejects?
    The retry finds no entry, starts the request again, and suspends again — the refetch loop reappears, driven by the outage. The error boundary never receives a stable failure, so the user sees a permanent spinner instead of an error state. Keep the rejected entry and expose an explicit invalidate or reset path for recovery.
  • How would you choose the key for a cached resource?
    Derive it deterministically from everything that changes the response — endpoint, entity id, filters, pagination, and any auth scope that changes the result — serialised in a stable order. Never include values that differ per render such as timestamps, freshly created objects, or function identities, since those guarantee a miss on every attempt.

saying these in an interview costs you the question

  • Says caching the resolved value alone is enough
  • Stores the entry only after the request completes
  • Drops the entry on rejection so the retry refetches
  • Uses one module-level Map on the server for user data
  • Assumes React retries failed requests automatically

context