Request Memoization in the App Router: The Hidden Cost of Fetching the Same Thing Twice
Next.js memoizes fetch() within a request, but the rules are stricter than most teams realize. Here's what actually dedupes, what silently doesn't, and how to stop paying for the same database call four times per page.

A few months ago we inherited a Next.js 15 project where a single product page was issuing seven identical calls to the same Postgres query. The team had read the docs. They knew about request memoization. They just didn't realize their code was quietly opting out of it on every render.
If you've ever added logging to a Server Component and seen the same query fire three, four, or ten times per page, this one's for you.
What request memoization actually is
In the App Router, React (not Next.js) deduplicates fetch() calls that happen within the same server render pass. If two Server Components both call fetch('https://api.example.com/user/42') with the same options, the second one gets the first one's result. The window is a single request — nothing is shared across users or across requests.
Next.js extends this by also layering its own data cache on top of fetch, but that's a separate concept with separate rules. Memoization is per-render. Data cache is persistent. Confusing the two is the root of most of the bugs we're about to walk through.
The mental model that keeps you sane
Think of it as three layers stacked on top of each other:
- Request memoization — React, in-memory, one request only, zero config.
- Data cache — Next.js, persistent across requests, controlled by
cacheandnext.revalidate. - Full route cache — Next.js, the whole rendered HTML, controlled by your route segment config.
When you call fetch() in a Server Component, all three can kick in. When you call a database driver directly, only layer two can — and only if you wrap it yourself.
The gotchas that cost us real money
Here's where teams lose. The docs are technically correct but easy to misread.
Gotcha 1: Direct database calls aren't memoized
This is the big one. React's memoization only applies to fetch(). If you're using Drizzle, Prisma, Kysely, or a raw pg client, nothing is deduped by default.
// app/product/[id]/page.tsx
import { db } from '@/lib/db';
async function getProduct(id: string) {
console.log('querying product', id);
return db.query.products.findFirst({ where: eq(products.id, id) });
}
// Called in page.tsx
// Also called in a <Breadcrumbs /> server component
// Also called in <RelatedProducts />
// Also called in <SchemaOrgJsonLd />
Four component trees, four queries. The fix is React's cache function:
import { cache } from 'react';
import { db } from '@/lib/db';
import { eq } from 'drizzle-orm';
import { products } from '@/lib/schema';
export const getProduct = cache(async (id: string) => {
console.log('querying product', id);
return db.query.products.findFirst({
where: eq(products.id, id),
});
});
Now all four callers share one query per request. cache keys on the argument identity, so pass primitives when you can.
Gotcha 2: Different headers break the cache key
Fetch memoization keys on the full request: URL, method, headers, body. If one caller adds an Authorization header and another doesn't, you get two requests. We've seen this with middleware that forwards an incoming cookie in some code paths but not others.
// Both of these look the same. They are not.
fetch('/api/me', { headers: { cookie: req.cookies.toString() } });
fetch('/api/me');
If you need per-user data, centralize the fetching in one helper so the header set is consistent.
Gotcha 3: POST is never memoized
Only GET and HEAD participate. If your backend exposes a GraphQL endpoint that you hit with POST, you're on your own. Wrap the function in cache and you get the same effect:
import { cache } from 'react';
export const getViewer = cache(async () => {
const res = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: VIEWER_QUERY }),
});
return res.json();
});
Gotcha 4: Memoization dies at the Suspense boundary... sort of
Memoization lives for the lifetime of a request. Suspense doesn't end the request — it just delays part of the tree. But if you're using loading.tsx plus parallel routes, each parallel segment still shares the same request context, so dedupe works across them. The surprise is that Route Handlers do not share a cache with the page that linked to them. If your page server-fetches /api/foo and your client component also fetches /api/foo on mount, those are two completely separate requests. Memoization can't help you there — you need the data cache or SWR-style client caching.
How to actually audit your app
Before optimizing, measure. We use a dirt-simple query counter patched into the DB client in development:
// lib/db-debug.ts
import { AsyncLocalStorage } from 'node:async_hooks';
type Ctx = { count: number; queries: string[] };
export const queryStore = new AsyncLocalStorage<Ctx>();
export function trackQuery(sql: string) {
const ctx = queryStore.getStore();
if (!ctx) return;
ctx.count++;
ctx.queries.push(sql);
}
Then wrap the root layout render in a store and dump the count in a dev-only header or a console.table at the end of the render. The first time you run it on a page you thought was clean, you'll find something embarrassing.
A quick triage checklist
- Does the same function get called from more than one Server Component in a route? → wrap in
cache. - Does the function take an object argument? → extract primitive keys or memoize on a stable string.
- Is the data user-specific but stable within a request (session, viewer, feature flags)? →
cacheis perfect; data cache is wrong because it'd leak across users. - Is the data public and reusable across requests? →
fetchwithnext.revalidateorunstable_cache.
When cache is the wrong tool
cache is not a performance cache. It doesn't persist. It doesn't survive to the next request. If you reach for it to "speed things up" across users, you've misunderstood what it does — and you probably want unstable_cache or the fetch data cache instead.
It's also not a substitute for a DataLoader. If you're resolving a list of 50 products and want to batch them into one IN (...) query, cache won't help. You still need batching logic. cache dedupes identical calls; it doesn't coalesce different ones.
// cache does nothing useful here — 50 different IDs, 50 different calls
const products = await Promise.all(
ids.map((id) => getProduct(id))
);
For that pattern, write a getProductsByIds helper that takes the full array, sort the IDs so the cache key is stable, and wrap that in cache.
Server Actions and memoization
Server Actions run in their own request context. A cache-wrapped function called from within a Server Action is memoized for that action's execution — not shared with the page render that triggered it. We've seen bugs where engineers expected the viewer query to be reused across the action and the subsequent re-render. It isn't. The action completes, Next.js revalidates, a fresh render happens, and cache starts from scratch.
If you need the same data in the action and the render, fetch it in both places and trust the data cache (or your database) to keep it cheap. Don't try to thread state between them.
What we'd do on day one of a new project
- Write every data-access function as
cache(async (...) => ...)from the start. The overhead is zero if nothing dedupes; the win is huge the first time something does. - Keep one data-access module per domain object. If
getProductlives in three files, you've already lost — each copy has its own memoization slot. - Add a dev-only query counter in your root layout. Fail CI if a known-good route exceeds a threshold.
- Document the difference between
cache,unstable_cache, andfetchcaching in your repo's README. Every team gets this wrong at least once; make it a one-link answer.
If you want a second pair of eyes on an App Router codebase that feels slower than it should, our team does this kind of audit regularly — see our web development services for how we usually scope it.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Dynamic `og:image` Routes in Next.js: Why Ours Rendered Blank for Two Weeks
Our OpenGraph images looked perfect in local dev and broken in every Slack preview. Here's the postmortem on dynamic `og:image` routes, Satori fonts, caching headers, and the bot UA quirks nobody warns you about.

Streaming Suspense Boundaries: Why Your TTFB Looks Great and Your LCP Still Sucks
Streaming SSR in the Next.js App Router makes TTFB look amazing. But if your Suspense boundaries are drawn wrong, LCP gets worse, not better. Here's how we diagnosed and fixed it on a real site.

React 19's `use` Hook in Server Components: The Waterfall We Didn't See Coming
The `use` hook promised cleaner data fetching in React 19. Then our TTFB doubled. Here's what went wrong, how we spotted it, and the patterns that actually work in Next.js App Router.
