Skip to content
intermediate Phase 5 · Data Fetching and Caching

Request Memoization and the Data Cache

Understand when identical fetch calls are reused.

50m
0 problems
Topic Progress 0%

Same URL, Twice: What Request Memoization Deduplicates Within a Render

A dashboard layout calls getUser() for the avatar and the page calls it again for the greeting. Both are fetch('/api/me') with the same headers, and on the server you would expect two HTTP round trips to your own API — you get one. Request memoization is a per-render-pass dedupe: while React renders the tree on the server, a GET fetch whose URL and options match an earlier one in that same pass returns the earlier Promise instead of going out again.

async function Sidebar() {
  const me = await fetch('/api/me', { headers: auth() }).then(r => r.json());
  return <Avatar user={me} />;
}
async function Greeting() {
  const me = await fetch('/api/me', { headers: auth() }).then(r => r.json());
  return <h1>Hello {me.name}</h1>;   // no second request — same pass, same key
}

The precise rules, because interviews probe the edges:

Property Behavior
Key URL + options (method, headers, body) must match exactly
Lifetime One render pass — the lifetime of the React render
Scope Across layouts, pages, and components rendered in that pass
Method GET (POST and friends are not memoized — they are not idempotent)
Interaction with Data Cache Independent — memoization dedupes, Data Cache reuses across requests

"Same options" means exactly same. Two calls that differ by a single header (one adds Authorization: Bearer A, the other Bearer B) are different keys and both go out. So do two calls where one sets cache: 'no-store' and the other does not — the option is part of the key. That detail matters when you "deduplicate" a shared helper and one caller passes { next: { revalidate: 60 } }: you have quietly created two cache entries, not one.

Scope is the other gotcha. The memo survives across the whole server render of that request — layout, page, and their children — but it does not survive to the next request, and it does not apply to a fetch in a client component's effect. It also does not apply to your ORM: db.query() twice in one render runs twice, because memoization is a React cache() mechanism wrapped around fetch, not a global request interceptor. To get the same dedupe for a database call, wrap it yourself (cache() from react, or the framework's cache helpers) — a common interview follow-up.

import { cache } from 'react';
export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});
// two components calling getUser(42) in one pass -> one query

Why it exists: component trees are composable, and composition is what makes every component fetch its own data — which would otherwise multiply requests per page. Memoization lets you write self-sufficient components without paying for the redundancy. The visible consequence in production is subtle: fewer upstream calls, no change to HTML. The cache that changes what the next request sees is the subject of the next chapter.

A quick way to prove memoization in a review: add a log line inside a shared getUser helper and render two components that call it during one request. One log entry per request, two callers — request memoization. Load the page again and you see a second entry, because the memo does not outlive the render. That experiment also reveals whether your helper looks identical but isn't: if two callers pass different default arguments, the keys differ and you get two entries, which is the most common reason a "deduplicated" helper still double-queries.

The Data Cache Across Requests: force-cache, no-store, and the Version Trap

Request memoization forgets between requests. The fetch data cache does not: it is a server-side store keyed by request (URL plus options) that persists across requests, users, and navigations until something revalidates it. That persistence is the source of every "why is my page stale?" incident and every "why did my API get hammered?" incident, depending on which way the default points.

The default has flipped, and knowing which era your app is in is half the answer:

Next.js version Default for fetch Practical meaning
14 and earlier cache: 'force-cache' GET results served from Data Cache until revalidated
15 and later cache: 'no-store' every request goes to the origin unless you opt in

You do not have to guess — read it off the codebase (next: { revalidate } or cache: options appear explicitly) or off next build's route table, where a route backed by uncached fetches is dynamic. Note the pre-15 nuance: force-cache applied to GET fetches only; mutations and any non-GET method were always uncached.

The two settings, stated as behavior rather than name:

// Always fresh — every request re-fetches, no shared store entry
const res = await fetch(url, { cache: 'no-store' });

// Shared and long-lived — one upstream hit for many requests
const res = await fetch(url, { cache: 'force-cache' });

// Shared with a lifetime: entry revalidates after 60 s
const res = await fetch(url, { next: { revalidate: 60 } });

no-store is per-call, not per-route: one no-store fetch in a deeply nested component makes the route dynamic, because the render now depends on request-time data. Teams debugging "why is my static page dynamic?" trace it exactly that way — find the uncached fetch (or the cookies() read that forces it) and decide whether it truly needs freshness.

Two limits keep this honest. First, the cache stores responses, not your schema: an ORM query, a filesystem read, and a message-queue peek are invisible to it. Caching those means an explicit wrapper (unstable_cache in older versions, cacheable functions with use cache in newer ones) with the same tag/revalidate vocabulary as fetch. Second, the cache is not a CDN: entries live where the app runs (server memory or configured persistence), and authorization interacts badly with sharing — if a fetch's result varies per user, the key must include whatever distinguishes them (headers, cookies in the options) or one user's data will be served to another. When in doubt, no-store for anything user-specific.

A third limit is about eviction: cache entries are not a memory budget you tune like a browser cache. There is no LRU knob to set and no guarantee an entry survives a deploy or a process restart — treat it as an optimization that may vanish, never as durable storage. Anything you must not recompute (a rate-limit window, a compiled token) belongs in a store you control, not in the fetch cache's absence of guarantees.

A failure log reads like this: a product page fetched /api/price with default caching, an engineer ships a price change, and the page shows the old number for hours — nobody set revalidate, and nobody set no-store, so the app was relying on whatever default its version shipped with. The fix is never "clear the cache and move on"; it is making the freshness contract explicit in code: either a revalidate window, a tag for on-demand invalidation, or no-store because the data is genuinely per-request.

One Request, Four Caches: Mapping What Is Reused and When It Expires

By the time an App Router request finishes, several independent stores may have participated. Interviewers distinguish candidates by whether they can name them separately, so here is the map with one example per layer.

Cache What it stores Lives Invalidated by
Request memoization Duplicate GET fetches in one render one render pass the render ending
Data Cache fetch results across requests server, persistent tags, paths, time, revalidate
Full Route Cache prerendered route HTML/RSC payload build output / server content or code change, revalidation
Router Cache client-side prefetched RSC payload browser memory time (dynamic: 0 s, static: 30 s) or navigation

The interactions are where the bugs live. A route rendered with a force-cache fetch can be prerendered — its HTML lands in the Full Route Cache, so even though the Data Cache holds a reusable entry, the next visitor reads the stored HTML without triggering a render at all. Adding no-store to that fetch does two things: it empties the Data Cache entry and drops the route out of the Full Route Cache, because the output now depends on request-time input. One option, two caches, both changed — which is why the fix for a stale page sometimes also changes your build output.

The client layer is separate and easy to forget. <Link> prefetch puts the RSC payload for a route into the Router Cache; for dynamic routes the prefetched shell expires almost immediately, for static routes it can live for about 30 seconds. So a user who clicks a link twice within a second may see the Router Cache's copy while the server's Data Cache has newer data. router.refresh() re-fetches the current route from the server while keeping client state — the standard move after a mutation, and the subject you will meet again under Revalidation with Time and Tags.

Worked example, end to end, for GET /products/42 on a Next 15 app:

1. Router Cache: hit? -> render proceeds, payload may be prefetched
2. Full Route Cache: hit? -> serve prerendered HTML, no render at all
3. render runs; page fetches /api/products/42 with revalidate: 60
4. Data Cache: entry < 60 s old? -> reuse (no upstream call)
5. otherwise -> upstream call, store, re-render
6. request memoization: any sibling that fetches the same URL reuses step 5

The debugging question is always which layer served this? The tools are ordinary: curl -H '...': ... -o /dev/null -D - to read x-nextjs-cache / cache-control headers, the next build route table for the static/dynamic classification, and logs around the upstream API to count real hits. When those disagree with your expectation, the version-default table from the previous chapter is the first thing to check — most "Next.js caching bugs" are an unexamined default meeting a stale deploy, not a flaw in the cache itself.

One last boundary: the Data Cache stores responses keyed by request. Two users calling the same /api/feed fetch share an entry only if the options (including any per-user headers or cookies you passed) are identical — so "cache hits" for personalized data usually mean your key accidentally included something per-user and nothing was shared, or worse, you omitted it and shared what you should not have. Audit cached fetches for both failure directions: too much sharing (authorization leak) and too little (a cache that never hits, invisible until you count upstream calls).

Quiz

1. During one server render, a layout and its child page both call fetch('/api/me', { headers }) with identical arguments. How many requests reach the API?

Question 1 options

2. In a Next.js 15 app, no cache option is passed to a GET fetch. What is the default behavior?

Question 2 options

3. A single deeply nested component adds cache: 'no-store' to its fetch. What is the effect on the route?

Question 3 options

4. You use an ORM (prisma.findMany) instead of fetch, twice in one render, and want fetch-like memoization. What do you do?

Question 4 options

Flashcards

Question

Request memoization vs Data Cache — lifetime, scope, and key?

Answer

Request memoization: one render pass, dedupes identical GET fetches (URL + options must match) across layouts/pages; vanishes when the render ends. Data Cache: persistent server-side store reused across requests and users, keyed by request, invalidated by tags, paths, time, or revalidate.

Question

What does cache: 'no-store' do, and what does it cost?

Answer

Every matching call bypasses the Data Cache and hits the origin — correct for per-user or always-live data. Cost: that render depends on request-time input, so the route leaves the Full Route Cache and becomes dynamic (ƒ). One nested no-store can flip a whole route.

Question

How did the default fetch caching behavior change across Next versions?

Answer

Next 14 and earlier: GET fetches default to force-cache (cached until revalidated). Next 15+: default is no-store (uncached unless you opt in). Non-GET methods were never cached. Verify with explicit options in code and the static/dynamic flags in next build output.

Question

Name the four caches involved in serving an App Router page.

Answer

Request memoization (per render pass), Data Cache (fetch results across requests), Full Route Cache (prerendered HTML/RSC on the server), Router Cache (prefetched RSC payload in the browser). Each has its own invalidation trigger; one cache option can move a route between layers.

Question

Why can a stale page survive a code fix, and what is the real fix?

Answer

Because a cached entry's freshness contract was implicit — the app relied on whatever default its Next version shipped with. The durable fix is making it explicit: revalidate: N for a window, a tag for on-demand invalidation, or no-store for per-request data; then verify by counting upstream hits and reading cache headers.

Revision Notes

Key Takeaways

  • 1. Identical GET fetches (URL + options) are deduped for one render pass — memoization, not caching; it forgets at the end of the request
  • 2. The Data Cache persists across requests and users, which is why staleness and accidental shared reads both come from the same mechanism
  • 3. Next 15 changed the fetch default from force-cache to no-store — unexamined defaults are the root cause of most caching surprises
  • 4. One no-store fetch anywhere makes the route dynamic and evicts its Full Route Cache entry; options propagate upward
  • 5. Database calls bypass fetch caching entirely — explicit cache() wrappers or tagged cache functions are required to reuse them

Interview Tips

  • • "Difference between memoization and the data cache?" — lifetime is the discriminator: one render pass vs across requests; add that memoization is React cache() under the hood and works even for no-store fetches
  • • "How would you debug a stale page?" — identify the layer (cache headers, build table), check the version default, then make the contract explicit: tag, revalidate window, or no-store
  • • "Why did my static route become dynamic?" — chase request-time reads: no-store fetch, cookies, headers, uncached DB access; note the Full Route Cache consequence, not just the Data Cache
  • • "Do ORM queries get cached?" — no; name React cache() for per-render dedupe and tagged cache wrappers for cross-request reuse

Cheat Sheet

Request Memoization and the Data Cache

Per-call options:

fetch(url, { cache: 'no-store' });              // fresh every request
fetch(url, { cache: 'force-cache' });           // shared, until revalidated
fetch(url, { next: { revalidate: 60 } });       // shared, refresh after 60 s
Next version Default for GET
<= 14 force-cache
>= 15 no-store
Non-GET methods are never cached.

Request memoization: same URL + same options, GET, one render pass, spans layout/page/children. Does not survive to the next request; does not cover db.query() — wrap those in React cache().

Four layers:

Cache Stores Invalidated by
request memoization duplicate fetches in one pass render ending
data cache fetch results across requests tags / paths / time / revalidate
full route cache prerendered HTML + RSC revalidation, content or code change
router cache prefetched RSC in browser ~30 s static / 0 s dynamic, or router.refresh()

Consequence chain: add no-store -> Data Cache entry gone and route drops out of the Full Route Cache (output depends on request-time input).

Debug order: which layer served this? → curl cache headers → next build route table → upstream hit logs → version default.

Continue Learning