Skip to content
Chapter 32Lesson 5

Per-request memoization with React cache()

React's cache() memoizes a read for one server render, so every Server Component can fetch the session on its own while the work runs once, and the rule for choosing it over 'use cache'.

Picture a dashboard rendering. The layout reads the current user for the avatar, the page reads the current user to scope its query to the right org, and a <Nav> three levels deep reads the current user to highlight the active workspace. Each read resolves the session and hits the database, so one page makes three round-trips that return the same user.

No component is wrong here. Server Components compose by each fetching what they need instead of threading data down from an ancestor, and that independence is exactly what produces the duplicate reads. React’s cache() fixes it: wrap a function with it and the function runs once per render, with every caller sharing the one result. By the end of this lesson you’ll build the request-scoped data layer the authenticated app leans on, and know when to reach for cache() over the 'use cache' directive.

Start with an un-memoized reader, called in three places across one render.

lib/auth.ts
export const getCurrentUser = async () => {
const session = await auth.api.getSession({ headers: await headers() });
return session?.user ?? null;
};

The reader is correct on its own. The problem appears only when three components each call it in the same render.

app/(app)/layout.tsx
const user = await getCurrentUser(); // for the avatar
app/(app)/dashboard/page.tsx
const user = await getCurrentUser(); // to scope the query
components/nav.tsx (three levels down)
const user = await getCurrentUser(); // to highlight the workspace

Each await getCurrentUser() fires on its own. Nothing is shared between them, so each resolves the session and hits the database independently: three round-trips for one identical value.

The obvious fix, read the user once at the top and pass it down as a prop, works for the layout and the page. But the nav is three levels deep, and threading the user through intermediate components that don’t use it is the prop-drilling this course avoids. Context won’t help either: it is client-side, and these are Server Components.

The duplication isn’t a bug in any one component. Each asks for the data it needs, exactly as a component should. The duplication is structural, so you can’t fix it at the call sites. You fix it at the function, and that is what cache() does.

cache is a named import from react, not from next/cache. After the last two lessons that may surprise you: this is a React primitive, not a Next.js one.

It takes a function and returns a memoized version of it. Memoization is the whole idea: run once, reuse the answer. Here is the canonical shape, and note where it lives, at module scope, not inside a component.

lib/auth.ts
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;
});

Call the wrapped function more than once with the same arguments during a single render, and its body runs once. Every caller gets back the same value, in fact the same Promise, resolved a single time. When the render finishes, the memo is discarded, so the next request starts clean.

Where the wrapper lives decides whether memoization works at all. The cache(...) call must sit at module scope, evaluated once when the module loads. Move it inside a component and you build a new memoizer on every render: each caller holds a different memoized function, so nothing is shared and nothing is deduplicated. No error appears, the page works, and you simply never get the benefit, the first of two failures that look 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”

The distinction the rest of this lesson rests on:

cache() deduplicates within one render and then forgets. 'use cache' persists across renders and across users.

These are not rival strategies for the same job. They live at two different layers with two different lifetimes.

Cross-request — 'use cache'

Request A

user 1

Request B

user 2 · seconds later

A writes · B reads

cache entry

keyed by args + source

one box, shared
Survives across requests. Shared across users until it expires or is invalidated.
Per-request — cache()

Request A · one render

layout page nav

in-render memo

runs once, all three share it

discarded when render ends
no sharing
between requests

Request B · its own render

layout page nav

its own fresh memo

separate, runs again

discarded when render ends
Born and dies inside one render. Never shared between requests.
Two layers, two lifetimes. 'use cache' writes one entry that any later request can read — shared across users. cache() spins up a throwaway memo inside a single render; a second request gets its own, and nothing ever crosses between them.

The cross-request layer is 'use cache', which you already know. An entry is keyed by the function’s arguments, captured variables, and source, then stored in the server’s cache backend and served to any request whose key matches until it expires or is invalidated. Request A renders it and writes the entry; Request B, a different user seconds later, reads it without re-running the work. Sharing across users is the whole point.

The per-request layer is cache(), and its life is far shorter. The memo is created when a render begins and discarded the moment it completes. User B hitting the same route in the same second gets a fresh memo of their own; nothing crosses between the two requests. With no persistence, there is nothing to evict or invalidate, and no cacheLife or cacheTag. Those are the cross-request controls from Lifetimes and tags, and they have no meaning here.

The rule, which the next sections sharpen: request-dependent work goes to cache(), request-independent work to 'use cache'.

cache() keys its memo by argument identity, and identity means two different things depending on the type:

  • For primitives like a string or number, identity is value equality. The same userId string passed twice is the same key: one entry, one run.
  • For objects, including arrays, identity is reference equality. Two objects with identical contents are still two references if they were built separately, so they count as two keys and run twice.

The object case is the one people get wrong, because it fails silently: the code runs, the result is correct, and you just do the work twice.

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 call

Deduplicates: 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.

The rule follows directly: prefer primitive arguments. Pass a userId or orgId string and deduplication is automatic. If you must pass an object, keep it a stable reference, resolved once per render and threaded down rather than rebuilt at each call site. A function that takes no arguments and reads what it needs itself sidesteps the question entirely, which is how the session reader is built: it takes nothing and reads headers() internally, so there is no argument to get wrong.

When to reach for cache() vs ‘use cache’

Section titled “When to reach for cache() vs ‘use cache’”

You hold two caching tools. Which one goes on a given function?

It comes down to one question: does the work depend on request data?

If the function reads cookies(), headers(), the resolved session or current user, or anything derived from params or searchParams, it’s cache(), and that’s the only option, not just the better one. As you saw in The use cache directive, reading a request API inside a 'use cache' boundary is a build error: the answer differs per request, so it can’t be cached across requests. cache() fits exactly this work, deduping the reads within one render and forgetting at the end.

If the work is request-independent, a CMS post fetched by slug, the product catalog, an expensive pure computation over serializable inputs, or a third-party API response, it’s 'use cache', which persists and shares the result across users. Reaching for cache() here would redo the work every request and throw away free cross-request reuse.

Putting both directives on one function is legal and harmless, just redundant, since cache() runs as an isolated per-render scope even inside a 'use cache' boundary.

The same question, made clickable:

Which caching layer does this function belong to?

Now sort 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.

cache() Per-request — touches the request, dedupe within one render
'use cache' Cross-request — request-independent, shared across users
Reads the current user’s session
Derives a value from cookies()
Scopes a database query by an org id read from await params
Fetches the marketing CMS post by its slug
Returns the product catalog
An expensive pure sort of a serializable list you want every user to share

The session readers in lib/auth.ts are where cache() pays off across the authenticated app: a small set of readers, each suited to a different need, all built on one cached read.

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. Every reader 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.

1 / 1

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. No prop-drilling, no fetching once at the top and threading the result down, no Context. All three resolve through one cached getSession, so the session is read from the database exactly once per render. The duplication we opened with isn’t patched at three call sites; it stops existing, at the function.

The two layers compose cleanly. These request-scoped readers sit on top; the request-independent fetchers they feed, such as a getProductCatalog(), are 'use cache' underneath.

Three boundaries qualify everything above, and each is a common point of confusion.

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 wanted is 'use cache'.

It does not invalidate. There is no cacheTag, no cacheLife, nothing to revalidate. The memo is born and destroyed inside one render, so there is never an old entry to clear.

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().

One last look at the silent failure from earlier: the cache() wrapper on getSession sits at module scope, evaluated once when lib/auth.ts loads. That placement is what makes deduplication work.