Per-request memoization with React cache()
React's cache function, the per-request memoizer that lets every Server Component read the session on its own yet runs the work once per render, and the rule for choosing it over use cache.
Picture a dashboard rendering. The layout reads the current user to show the avatar in the corner. The page reads the current user to scope its query to the right org. A <Nav> buried three levels deep reads the current user to highlight the active workspace. Each of those reads runs the same line: resolve the session, hit the database, get back the user. That is three database round-trips on one page, returning the exact same answer every time.
None of those three components is wrong. You learned in Server Components as the default that Server Components compose freely: each one fetches what it needs on its own, rather than waiting for some ancestor to fetch the data and thread it all the way down. That independence is the whole point, and it is also what produces the three identical reads. The fix is React’s cache(): wrap a function with it and the function runs once per render, with every caller in that render sharing the one result. By the end of this lesson you’ll be able to build the request-scoped data layer the entire authenticated app leans on, and you’ll know when to reach for cache() rather than the 'use cache' you learned in The use cache directive.
The duplicate-read problem
Section titled “The duplicate-read problem”Before naming the tool, look closely at the problem. The starting point is an un-memoized reader, imported and called in three places across one render.
export const getCurrentUser = async () => { const session = await auth.api.getSession({ headers: await headers() }); return session?.user ?? null;};That reader is correct on its own. The problem only shows up when three components each call it in the same render.
const user = await getCurrentUser(); // for the avatarconst user = await getCurrentUser(); // to scope the queryconst user = await getCurrentUser(); // to highlight the workspaceWhen React renders this tree, each await getCurrentUser() fires on its own. There is no shared state between them, so each one resolves the session and hits the database independently. Three round-trips for a value that is identical across all three.
The instinct from earlier component work is to read the user once at the top and pass it down as a prop. That works for the layout and the page, but the nav is three levels deep, and threading the user through every intermediate component that doesn’t itself use it is the kind of prop-drilling this course steers away from. Context won’t help either: it is a client-side mechanism, and these are Server Components running on the server.
Notice the real shape of the problem. The duplication is not a bug in any one component. Each component is correct on its own; it asks for the data it needs, which is what a well-behaved component should do. The duplication is structural, which means you can’t fix it at the call sites. You have to fix it at the function, and that is what cache() does.
What cache() does
Section titled “What cache() does”cache is a named import from react itself. That is worth noticing, because after the last two lessons you might reach for next/cache for anything cache-related. This one is different: it’s a React primitive, not a Next.js one.
It does one thing. You hand it a function, and it hands you back a memoized version of that function. Memoization is the whole idea: run once, reuse the answer. Here is the canonical shape, and notice where it lives: at module scope, not inside a component.
import { cache } from 'react';
export const getCurrentUser = cache(async () => { const session = await auth.api.getSession({ headers: await headers() }); // resolved once per request return session?.user ?? null;});The semantics are short. Call the wrapped function more than once with the same arguments during a single render, and its body runs once. Every caller gets the same returned value back, in fact the same Promise, resolved a single time. When that render finishes, the memo is thrown away, so the next request starts with a clean slate.
Where the wrapper lives is not a style preference; it decides whether the memoization works at all, and we’ll return to it at the end. The cache(...) call must sit at module scope, evaluated once when the module loads. Move it inside a component and you create a brand-new memoizer on every render. Each caller then holds a different memoized function, so nothing is shared and nothing is deduplicated. No error appears: the code runs, the page works, and you simply never get the benefit. This is the first of two failures that look completely normal yet quietly cost you the deduplication you came for.
Two cache layers: per-request and cross-request
Section titled “Two cache layers: per-request and cross-request”Here is the distinction the rest of this lesson rests on, and the one to carry forward into every future caching decision:
cache()deduplicates within one render and then forgets.'use cache'persists across renders and across users.
They are not two strategies competing for the same job. They live at two different layers with two different lifetimes. The following diagram draws both, and the rest of the lesson builds on it, so study it before reading the walkthrough.
Request A
user 1
Request B
user 2 · seconds later
cache entry
keyed by args + source
one box, sharedRequest A · one render
in-render memo
runs once, all three share it
discarded when render endsbetween requests
Request B · its own render
its own fresh memo
separate, runs again
discarded when render endsWalk through the two lifetimes, starting with the top lane.
The cross-request layer is 'use cache', which you already know. An entry is keyed by the function’s arguments, its captured variables, and its source. It’s stored in the server’s cache backend and served to any request whose key matches, until it’s invalidated or expires. Request A renders it and writes the entry; Request B, a different user seconds later, reads the same entry without re-running the work. Sharing across users is the entire reason it exists.
The per-request layer is cache(), and its life is much shorter. The memo is created when a render begins, lives only as long as that render, and is discarded the moment it completes. User B hitting the same route in the same second gets their own fresh memo, and nothing is shared between the two requests. There is no persistence here, so there is nothing to evict, nothing to invalidate, and no cacheLife or cacheTag. Those are the cross-request controls from Lifetimes and tags, and they have no meaning at this layer. Noting that now saves you from reaching for them later.
In one line, which you’ll formalize in a moment: request-dependent work goes to cache(), and request-independent work goes to 'use cache'.
Argument identity decides what dedupes
Section titled “Argument identity decides what dedupes”One mechanic inside cache() isn’t obvious from the outside, and it’s where the second silent failure hides. The memo is keyed by argument identity, and identity means two different things depending on the type:
- For primitives like a string or a number, identity is value equality. The same
userIdstring passed twice is the same key, so you get one entry and one run. - For objects, including arrays, identity is reference equality. Two objects with byte-for-byte identical contents are still two different references if they were constructed separately, so they count as two keys and trigger two runs.
The second case is the one that catches people, because it fails the same way the module-scope mistake does: silently. The code runs, the result is correct, and you just do the work twice. Compare the two shapes directly.
export const getMembership = cache(async (orgId: string) => { const user = await getCurrentUser(); return findMembership(user?.id, orgId);});
// Two components, same org id:await getMembership('org_42');await getMembership('org_42'); // same key — served from the first callDeduplicates: the same string is the same key, so the body runs once. Primitive arguments are compared by value, which is why passing an id string is the safe default.
export const getMembership = cache(async (scope: { orgId: string }) => { const user = await getCurrentUser(); return findMembership(user?.id, scope.orgId);});
// Two components, two freshly built objects:await getMembership({ orgId: 'org_42' });await getMembership({ orgId: 'org_42' }); // new object, new key — runs againRuns twice: two object literals are two different references, so they miss each other entirely. No error appears; you just resolved the membership twice with no warning.
The practical rule falls straight out of this: prefer primitive arguments. Pass a userId or an orgId string and deduplication is automatic and reliable. If you genuinely need to pass an object, make sure it’s a stable reference, resolved once per render and threaded down rather than rebuilt at each call site. One shape sidesteps the whole question: a function that takes no arguments at all and reads what it needs itself. That is how the session reader from earlier is built. It takes nothing and reads headers() internally, so there is no argument to get wrong. You’ll see why that matters when we assemble the production layer.
When to reach for cache() vs ‘use cache’
Section titled “When to reach for cache() vs ‘use cache’”You now hold two caching tools. Given a function, which one do you put on it?
Everything turns on a single question: does the work depend on request data?
If the function reads cookies(), headers(), the resolved session or current user, or a value derived from params or searchParams, anything that differs per request, then it’s cache(). Here the choice isn’t merely better, it’s the only option. As you saw in The use cache directive, reading a request API inside a 'use cache' boundary is a build error. You couldn’t cache this work across requests if you wanted to, because its answer is different for every request. cache() is the per-request equivalent for exactly this work: dedupe within the render, forget at the end.
If the function is request-independent, such as a CMS post fetched by slug, the product catalog, an expensive pure computation over serializable inputs, or a response from a third-party API, then it’s 'use cache'. It persists and is shared across users. Reaching for cache() here would redo the work on every request and throw away the cross-request reuse you could have had for free.
One honest footnote: putting both directives on a single function is legal and not harmful, just redundant, because cache() runs as an isolated per-render scope even inside a 'use cache' boundary. There’s no need to agonize over the choice. Answer the one question, does this touch the request, and the layer picks itself.
The following decision walker is the same question, made clickable. Try running a function through it.
The work is request-scoped, so it can’t be cached across requests: a 'use cache' boundary that reads a request API is a build error. cache() is the only option. It dedupes the repeated reads within one render and forgets at the end. The canonical example is the session-read ladder you’ll build next.
A cross-request store keyed by arguments, shared across users. Add cacheLife and cacheTag to control freshness and invalidation. Think a CMS post fetched by slug, or the product catalog.
The work is pure, but you only want in-render deduplication and don’t want it persisted, such as an expensive computation you’d rather not store across requests. This case is rare but real, and it keeps the decision honest rather than forcing a false binary.
That one decision, which layer the work belongs to, is the whole job. Practice it now by sorting real functions into the right bucket.
Sort each function by the one question that decides it: does its work depend on request data? Drag each item into the bucket it belongs to, then press Check.
cookies()await paramsThe request-scoped data layer
Section titled “The request-scoped data layer”Now assemble the production shape this all builds toward. Across the authenticated app, the canonical use of cache() is the session-read ladder that lives in lib/auth.ts: a small set of cached readers, each suited to a different need.
import { cache } from 'react';
const getSession = cache(async () => { return auth.api.getSession({ headers: await headers() });});
export const getCurrentUser = async () => { const session = await getSession(); return session?.user ?? null;};
export const requireUser = async (next?: string) => { // Unit 8 — returns the user or redirects to /sign-in};
export const requireOrgUser = async (role?: string) => { // Unit 8 — returns { user, orgId, role } or redirects};The single cached read. Everything in the ladder funnels through this one cache()-wrapped getSession.
import { cache } from 'react';
const getSession = cache(async () => { return auth.api.getSession({ headers: await headers() });});
export const getCurrentUser = async () => { const session = await getSession(); return session?.user ?? null;};
export const requireUser = async (next?: string) => { // Unit 8 — returns the user or redirects to /sign-in};
export const requireOrgUser = async (role?: string) => { // Unit 8 — returns { user, orgId, role } or redirects};getCurrentUser builds on that cached read, returning the user or null. It’s the reader for surfaces that render differently signed in versus signed out.
import { cache } from 'react';
const getSession = cache(async () => { return auth.api.getSession({ headers: await headers() });});
export const getCurrentUser = async () => { const session = await getSession(); return session?.user ?? null;};
export const requireUser = async (next?: string) => { // Unit 8 — returns the user or redirects to /sign-in};
export const requireOrgUser = async (role?: string) => { // Unit 8 — returns { user, orgId, role } or redirects};requireUser and requireOrgUser are siblings on the same cached read, the readers for protected pages, built out fully in the auth unit later in the course.
import { cache } from 'react';
const getSession = cache(async () => { return auth.api.getSession({ headers: await headers() });});
export const getCurrentUser = async () => { const session = await getSession(); return session?.user ?? null;};
export const requireUser = async (next?: string) => { // Unit 8 — returns the user or redirects to /sign-in};
export const requireOrgUser = async (role?: string) => { // Unit 8 — returns { user, orgId, role } or redirects};Because that one call is cached, it runs exactly once per render, no matter how many of the three helpers fire across the tree.
Look at what this buys you, because it’s the structural answer to the problem we opened with. Every Server Component imports the reader it needs and calls it freely: the layout calls getCurrentUser, the page calls requireOrgUser, the deep nav calls getCurrentUser again. There’s no prop-drilling, no fetching once at the top and threading the result down, and no Context. The three helpers all resolve through one cached getSession, so the framework guarantees the session is read from the database exactly once per render. The duplication we started with doesn’t get patched at three call sites; it stops existing, at the function.
This is the request layer doing exactly its job, and the two layers compose cleanly. These request-scoped readers sit at the top. The request-independent fetchers they feed into, such as a getProductCatalog() or a marketing page, are 'use cache' underneath: request-scoped readers above, cross-request fetchers below.
What cache() will not do
Section titled “What cache() will not do”A few boundaries are worth stating plainly, because each is a common point of confusion. They qualify what you just learned, so keep them next to it.
It does not persist across requests. A second user gets their own memo. If you reached for cache() hoping for cross-request reuse, the tool you actually wanted is 'use cache'. This is the most common mix-up carried over from older mental models.
It does not invalidate. There is no cacheTag, no cacheLife, nothing to revalidate. The memo is born and destroyed inside one render, so invalidation is meaningless: there’s never an old entry to clear. Don’t bring the post-mutation tools from later in this chapter to this layer.
It does not cross the server/client boundary. It’s a server-render primitive. Client Components share values through Context or props, not through cache().
Now that you’ve seen the real ladder, take one last look at the silent failure from earlier. Notice that the cache() wrapper on getSession sits at module scope, evaluated once when lib/auth.ts loads. That placement is what makes the whole thing work.
Fill the two blanks so the wrapper actually deduplicates. Pick the right option from each dropdown, then press Check.
cache is imported from , and the wrapper must sit so it’s created only once.
External resources
Section titled “External resources”The official primitive: memoization scoped to one server render, with the caveats that matter.
A short, worked explainer of why RSCs need cache() and how one fetch ends up shared across components.
The cross-request side of the story: 'use cache', and how to pass per-request values into a cached function.