React cache() Function: Deduplication for Server Components
React's cache() function deduplicates async data fetching in Server Components — here's how it actually works, when to use it, and where it falls short.
What React cache() Actually Does
Honestly, most developers discover cache() by accident — they're deep in a Server Components refactor, they've got three different components all calling the same getUser(id) function, and they start wondering why they're hitting the database three times per render. That's exactly the problem cache() solves.
The cache() function, exported from the react package since React 19 (and available experimentally in React 18.3+), wraps an async function and memoizes its results within a single server request. If two components both call getUser(42) with the same argument during the same render pass, the underlying function only executes once. The second call returns the already-resolved value instantly.
This is fundamentally different from module-level caching or a global Map. The cache is scoped to a single request lifecycle — it gets discarded after the response is sent. No stale data across requests. No memory leaks from long-lived in-memory stores. It's automatic, request-scoped deduplication.
Setting Up cache() in a Next.js 15 App
The API is deliberately minimal. You wrap your data-fetching function once, export the wrapped version, and every component that imports it gets automatic deduplication. Here's a real-world example:
import { cache } from 'react';
import { db } from '@/lib/db';
// Wrap once at the module level
export const getUser = cache(async (userId: string) => {
console.log(`Fetching user ${userId} from DB`);
const user = await db.users.findUnique({ where: { id: userId } });
return user;
});
export const getUserPermissions = cache(async (userId: string) => {
const perms = await db.permissions.findMany({
where: { userId },
});
return perms;
});Now in your Server Components, you call getUser(id) freely. If <ProfileHeader>, <ProfileSidebar>, and <ProfileSettings> all call getUser(userId) with the same ID, the database only sees one query. That console.log fires exactly once. If you need to manage UI state alongside your data (like loading indicators), check out how React toast notifications handle async feedback — the patterns complement each other well.
One thing that trips people up: the cache key is based on argument identity. getUser('42') and getUser(42) are different cache entries because '42' !== 42. Strings, numbers, and objects all work, but object arguments are matched by reference, not deep equality. Keep your arguments primitive where possible.
cache() vs fetch() Deduplication: What's the Difference?
Next.js has had built-in fetch() deduplication for a while — if you call fetch('https://api.example.com/user/42') in multiple Server Components during the same render, Next.js collapses those into a single network request. So why do you need cache() at all?
The answer is anything that isn't fetch(). ORM queries, file system reads, Redis lookups, calls to internal SDK functions, database transactions — none of those go through fetch(). If you're using Prisma, Drizzle, or a direct pg client, you get zero automatic deduplication without cache(). That's a lot of accidental N+1 queries waiting to happen.
There's also the composability angle. With cache(), you can write your data layer however you want — raw SQL, an ORM, a third-party SDK — and still get consistent deduplication semantics. You're not forced into HTTP-based patterns just to avoid duplicate work. For a broader look at React performance patterns beyond caching, react-performance-guide covers memoization, lazy loading, and bundle splitting in one place.
Preloading Data with cache() and the Preload Pattern
Here's a pattern that's genuinely useful but underused: combining cache() with a preload() helper to kick off data fetching before a component tree renders. React's component model means data fetching normally starts when a component renders — which means deeper components have to wait for their parents first. Waterfall city.
import { getUser } from '@/lib/data';
// Call this in a parent component or layout
export function preloadUser(userId: string) {
// Trigger the fetch without awaiting — it populates the cache
void getUser(userId);
}
// In your layout or page:
export default async function UserLayout({
params,
children,
}: {
params: { userId: string };
children: React.ReactNode;
}) {
// Start fetching immediately — don't await here
preloadUser(params.userId);
// Meanwhile, fetch what the layout actually needs
const session = await getSession();
return (
<SessionProvider session={session}>
{children}
</SessionProvider>
);
}When children eventually renders and calls getUser(params.userId), the result is already in the cache. No waterfall. The fetch started as soon as the layout ran, and by the time any child component asks for the data, it's ready. This pairs well with Suspense boundaries — you get parallel data fetching without prop drilling the resolved values through the tree.
The preload pattern also helps with TypeScript. Since preloadUser returns void, it makes the intent clear: this is a side-effect call, not a data dependency. If you want TypeScript patterns that go deeper, react-typescript-tips has a section on typing async Server Components that's worth reading.
Limitations You Should Know Before Shipping
The request-scoped lifetime is a feature, but it's also a constraint. cache() won't help you across requests. If user A and user B both request the same page, they get separate cache instances. For cross-request caching, you still need Redis, an in-memory store with TTL logic, or Next.js's unstable_cache (which is a different API entirely and has its own invalidation semantics).
There's no built-in cache invalidation within a request either — once a value is cached, it stays cached for the duration of that render. That's fine for read-heavy data fetching. It would be a problem if you called a mutation mid-render and expected subsequent reads to see the updated state. Don't do that. Mutations belong in Server Actions, not inside components.
Also worth knowing: cache() only works in React Server Component contexts. It does nothing in Client Components. Calling a cached function from a Client Component is either impossible (if the function imports server-only modules) or just runs the function normally without caching. The deduplication only happens on the server, during the RSC render pass. Does this mean you need different patterns for client-side state? Yes — but that's what theme-toggle-react and similar client-side component patterns are designed for.
Real Request Deduplication: Numbers That Matter
Let's put some numbers on this. Imagine a dashboard page with 8 Server Components that each need the current user's data. Without cache(), that's 8 database queries. With a typical Postgres query taking 2-5ms on a local network, you're looking at 16-40ms of unnecessary serial or parallel DB work — plus 8x the connection pool usage.
With cache() wrapping getUser(), it's 1 query. The remaining 7 components get the cached Promise. In a high-traffic scenario with 500 concurrent requests, that difference compounds fast. You're not just saving milliseconds — you're reducing load on your database, your connection pool, and any downstream services that get called as part of user lookups.
The gains are biggest for data that multiple components in the same tree share: the authenticated user, feature flags, tenant config, subscription status. These are exactly the things that every component wants to know and that you'd otherwise either prop-drill (messy) or refetch (wasteful). cache() gives you a third option: fetch once, share everywhere, stay clean.
Combining cache() with Suspense and Error Boundaries
One underappreciated aspect: cache() works naturally with React Suspense. When a cached async function is called and the result isn't available yet, React suspends the component — just like it would with any Promise thrown during render. When the Promise resolves, React resumes. The fact that it's cached doesn't change the Suspense mechanics; it just means fewer total Promises get created.
Error boundaries work the same way. If a cached function throws (database down, network timeout, etc.), the Error Boundary catches it. You don't get any special retry behavior from cache() itself — errors aren't cached, so a retry will call the underlying function again. That's actually the right behavior: you want stale successes deduplicated, but errors should be retriable.
For production apps, wrap your cached data functions in try/catch and return typed result objects rather than throwing. This makes your error states explicit and keeps Suspense boundaries focused on loading states rather than error recovery. The pattern is verbose but honest — you always know what you're dealing with.
When You Should and Shouldn't Use cache()
Use cache() for any async function that: (1) fetches read-only data, (2) might be called by multiple components in the same render, and (3) is expensive enough that duplicate calls matter. Database queries, API calls, file reads, and SDK lookups all qualify. Wrap them once at the module level and never think about deduplication again.
Don't use cache() for functions with side effects. If your function writes to the database, sends an email, or mutates external state, deduplication is the last thing you want — you need those calls to execute every time. Same goes for functions that depend on time or randomness: caching Date.now() or Math.random() would be a bug, not an optimization.
The rule of thumb: if calling the function twice with the same arguments should always return the same value (pure read semantics), wrap it in cache(). If calling it twice has different effects or returns different values by design, leave it alone. That mental model maps cleanly onto what cache() actually guarantees — and it'll save you from a category of bugs that are genuinely hard to debug in a Server Components world.
FAQ
It's available experimentally in React 18.3+ but was stabilized in React 19. If you're using Next.js 14+ with the App Router, you can import it from 'react' — Next.js bundles a compatible version. Check your react package version: 18.3.0 or higher gets you the experimental API, 19.0.0 gets you the stable one.
No. cache() only deduplicates within the React Server Component render pass on the server. Calling a cached function from a Client Component either throws (if it uses server-only imports) or runs normally without any caching. For client-side data fetching deduplication, you'd use something like SWR's deduplication interval or React Query's query caching.
React cache() is request-scoped — the cache lives for one server render and is discarded. Next.js unstable_cache persists across requests (it's backed by the data cache) and supports time-based revalidation and tag-based invalidation. Use cache() for within-request deduplication, unstable_cache for cross-request persistence with controlled invalidation.
Yes, that's one of the primary use cases. Wrap your Prisma query functions: export const getUser = cache(async (id: string) => prisma.user.findUnique({ where: { id } })). Every Server Component that imports and calls getUser with the same id during a single request hits the database exactly once.
Errors are not cached. If a cached function throws or rejects, that failure is not stored — the next call with the same arguments will invoke the underlying function again. Only successful resolutions are cached within the request. This is intentional: you want retries to work, and you don't want a transient error to poison the cache for the rest of the render.
No. cache() is a server-side API and is not included in client bundles. The 'react' package ships different exports for server and client environments. If you accidentally try to use cache() in a client bundle, you'll get a runtime error or a no-op depending on your bundler configuration, not a bundle size increase.