How to Think About Caching in Next.js Apps
A few years ago, “caching” in a web app meant one or two things: maybe a Cache-Control header, maybe Redis in front of your database. You could hold the whole picture in your head.
That world is gone. A modern Next.js app has caching happening in at least eight different places at once and most of them are turned on by default, invisible, and don’t know the others exist. The browser is caching. The CDN is caching. The Next.js Router is caching on the client. The server is memoizing requests, caching data, and caching whole routes. Your data-fetching library is caching. And underneath all of it, your database and Redis are caching too.
Here’s the thing nobody tells you when you start: caching is not a performance optimization you bolt on at the end. It’s an architectural decision you make at the beginning. If you treat it as an afterthought, you don’t get a slow app — you get a correct-looking app that occasionally serves a user someone else’s data, shows a price that changed an hour ago, or renders a “logged out” navbar to a logged-in user. And those bugs are the worst kind: intermittent, environment-dependent, and nearly impossible to reproduce locally.
This article is the mental map I wish I’d had before my first serious project. The goal isn’t to memorize every API. It’s to build a model of where data can get stuck so that when something goes stale, you know which drawer to open.
The one idea that makes all of this tractable
Caching is state management across time, distributed across layers you don’t fully control.
Read that again, because it reframes the whole problem. Every cache is just a copy of some data, frozen at a moment, living somewhere along the path between your database and the user’s eyeballs. A “caching bug” is almost never a bug in a cache. It’s a mismatch between how fresh you assumed the data was and how fresh it actually is at some layer.
So the real skill isn’t knowing the APIs. It’s being able to answer, for any piece of data on your screen: which layers is this sitting in right now, and what would it take to refresh each one?
To answer that, you need the map.
The map: a request’s journey
Think of data flowing through five zones. Each zone can hold a copy.
┌──────────────────────────────────────────────┐
│ THE BROWSER │
│ bfcache · HTTP cache · Service Worker │
└──────────────────────────────────────────────┘
↓ ↑
┌──────────────────────────────────────────────┐
│ THE CLIENT APP (JS) │
│ Next.js Router Cache · RTK Query / SWR │
└──────────────────────────────────────────────┘
↓ ↑
┌──────────────────────────────────────────────┐
│ EDGE / CDN │
│ Static assets · Full HTTP responses │
└──────────────────────────────────────────────┘
↓ ↑
┌──────────────────────────────────────────────┐
│ THE NEXT.JS SERVER │
│ Request Memoization · Data Cache │
│ Full Route Cache · 'use cache' (PPR) │
└──────────────────────────────────────────────┘
↓ ↑
┌──────────────────────────────────────────────┐
│ THE BACKEND / ORIGIN │
│ Redis / app cache · DB query cache │
└──────────────────────────────────────────────┘When you debug a stale-data issue, you walk this stack top to bottom (or bottom to top) and ask “is the old value living here?” until you find it. Let’s go through each zone the way I think about it in practice.
Zone 1 The browser
This is the layer engineers forget exists, because it’s not in your codebase.
The HTTP cache. The classic one. Driven by response headers — Cache-Control, ETag, Expires. If you set Cache-Control: max-age=3600 on an API response, the browser will happily serve a one-hour-old copy without ever asking your server. Powerful, and a great way to ship a bug that only some users see (the ones whose cache hasn't expired).
The back/forward cache (bfcache). When a user hits “back,” modern browsers often restore the entire page from memory — DOM, JavaScript heap, scroll position — without re-running anything. This is fantastic for UX and occasionally infuriating: a user logs out, hits back, and sees the logged-in page because nothing re-executed. If you have state that must re-validate on navigation, you handle it with a pageshow listener checking event.persisted.
Service Workers (if you have a PWA) add yet another programmable cache. Most apps don’t, so I’ll leave it there.
The mental note for Zone 1: you don’t control when this clears. You can only influence it with headers and event hooks. So be conservative with Cache-Control on anything personalized or fast-moving.
Zone 2 The client app
Now we’re in JavaScript you wrote (or imported).
The Next.js Router Cache. This is a client-side, in-memory store of the React Server Component payloads for routes the user has visited or prefetched. Its entire job is to make navigation feel instant — click a link, and if the segment is in the Router Cache, no server round-trip happens.
This is also where a lot of “I mutated the data but the page didn’t update” confusion comes from. You ran a Server Action, the database changed, but the user navigates back to a page whose RSC payload is still sitting in the client Router Cache. The fix is to invalidate it explicitly — revalidatePath / revalidateTag from a Server Action, or router.refresh() on the client.
One honest caveat: the lifetime of this cache (the staleTimes config) has changed meaningfully across Next.js versions — the defaults in v13/14 were more aggressive than what you get today, where dynamically-rendered routes are barely cached client-side by default. Don't memorize a number; know that it's short, configurable in next.config, and version-dependent. Verify against the version you're actually running.
Your data library — RTK Query, TanStack Query, SWR. If you’re fetching in Client Components, you almost certainly have one of these, and each maintains its own cache with its own invalidation model. RTK Query’s is tag-based and genuinely excellent once you commit to it:
// Define what each endpoint provides and invalidates
const api = createApi({
endpoints: (build) => ({
getPosts: build.query({
query: () => "posts",
providesTags: ["Posts"],
}),
addPost: build.mutation({
query: (body) => ({ url: "posts", method: "POST", body }),
// This single line keeps the cache honest:
invalidatesTags: ["Posts"],
}),
}),
});The lesson that took me too long to internalize: pick one source of truth per piece of data. If the same list of posts is fetched once by the server (and cached there) and again by RTK Query on the client (and cached there), you now have two caches with two clocks and no coordination. That’s a stale-data factory. Decide whether a given dataset is “server-owned” or “client-owned” and don’t let it live in both.
Zone 3 The edge / CDN
For static assets — JS bundles, images, fonts — this is a solved problem and you mostly don’t think about it. Next.js fingerprints filenames, so a new build produces new URLs and the CDN cache invalidates itself naturally.
The interesting case is when the CDN caches full HTML/RSC responses for your statically-rendered routes. This is where Next.js’s edge story shines — a fully static page can be served from a CDN edge node geographically close to the user, never touching your origin. The trap: a CDN cache and your server’s Data Cache are different layers. Purging one does not purge the other. I’ve watched a team purge the Data Cache, confirm the origin was serving fresh data, and still field “it’s stale!” reports for an hour — because the CDN edge was happily serving its own copy. When something is wrong at the edge, you need an edge-level purge, full stop.
Zone 4 The Next.js server (where the real complexity lives)
This is the zone that has changed the most, so let me first clear up the single biggest source of outdated advice on the internet.
fetch is no longer cached by default. In the early App Router days (v13/14), every fetch you wrote was silently cached forever unless you opted out. This caused enormous confusion. Since v15, the default flipped: fetch requests are not cached unless you explicitly ask. If you read a tutorial that says "Next.js caches all your fetches automatically," it's describing a world that no longer exists.
Get Farhan Asghar’s stories in your inbox
Join Medium for free to get updates from this writer.
With that out of the way, here are the server-side layers, from shortest-lived to longest:
- Request Memoization. Within a single render pass, if you call the same
fetch(same URL + options) in five different components, it executes once. This is React's deduplication — it exists so you can fetch the same data wherever you need it without prop-drilling, and not pay for it five times. It's ephemeral: gone the moment the render finishes. For non-fetchdata sources (a database client, a CMS SDK), you opt in with React'scache():
import { cache } from "react";
// Now calling getUser(42) twice in one render hits the DB once.
export const getUser = cache(async (id: number) => {
return db.user.findUnique({ where: { id } });
});The classic mistake here: thinking cache() is persistent. It is not. It's per-request memoization. Using it to "cache" a DB result across requests does nothing — the result is thrown away after each render.
2. The Data Cache. This is the persistent one. It stores fetch/computation results across requests, across users, and even across deployments, until you revalidate. This is your workhorse for “data that’s the same for everyone and changes infrequently” — a marketing page’s content, a product catalog, a blog.
3. The Full Route Cache. At build time, statically-renderable routes get their HTML + RSC payload baked and stored. Subsequent requests serve the baked version with zero rendering. This is what makes a static Next.js page absurdly fast. It clears on redeploy.
The modern model: Cache Components and use cache
Here’s where it’s worth knowing what’s current, because Next.js 16 introduced a genuinely different philosophy. Instead of caching being an implicit default you fight against, the new Cache Components model (enabled with cacheComponents: true) makes caching explicit and opt-in via the use cache directive.
// next.config.ts
const nextConfig = { cacheComponents: true };
export default nextConfig;import { cacheLife, cacheTag } from "next/cache";
// Cache an entire component's output
async function BlogPosts() {
"use cache";
cacheLife("hours"); // revalidate roughly hourly
cacheTag("posts"); // tag it for targeted invalidation
const res = await fetch("https://api.example.com/blog");
const posts = await res.json();
return <PostList posts={posts} />;
}The mental model is clean and, in my opinion, the right one: anything not explicitly marked use cache is assumed dynamic. If a component reads request-time data (cookies, headers, search params) or does something non-deterministic, you wrap it in <Suspense> and it streams at request time instead of caching:
import { Suspense } from "react";
export default function Page() {
return (
<>
<BlogPosts /> {/* cached, in the static shell */}
<Suspense fallback={<Spinner />}>
<UserGreeting /> {/* per-user, streams in live */}
</Suspense>
</>
);
}This is Partial Prerendering (PPR): one page, with a cached static shell sent instantly and dynamic “holes” streamed in per request. It’s the default behavior once Cache Components is on, and it dissolves the old “is this page static or dynamic?” binary — a single page can be both.
Invalidation in this model is tag-driven and immediate. A Server Action mutates data and calls updateTag("posts"), and the next visitor sees fresh content:
async function createPost(formData: FormData) {
"use server";
await db.post.create({ data: { title: formData.get("title") } });
updateTag("posts"); // expires the 'posts' cache everywhere it lives on the server
}A practical note for serverless deployments: use cache stores entries in-memory by default, and in serverless that memory doesn't survive between requests so a cache can quietly re-evaluate every time. For durable, shared caching across instances, there's a remote variant ('use cache: remote'). Worth knowing before you assume your cache is working in production when it's really cold every request.
Zone 5 The backend / origin
Finally, behind Next.js, your own infrastructure: a Redis or in-process application cache for expensive computations, and the database’s own query cache. As a backend engineer this is probably your home turf, so I won’t belabor it. The one cross-cutting point: this layer’s invalidation has to be coordinated with the layers above it. If you bust your Redis cache but the Next.js Data Cache still holds the old value, the user sees old data. Caches don’t cascade their invalidations for free — you wire that up.
The framework: how to decide, up front
Now the payoff. Before you write a single fetch, run each meaningful piece of data through five questions. This is the "mind map" — do it on paper, in a design doc, whatever at the start.
1. Who owns this data, and who can see it? Is it global (same for every user) or personalized (per-user, per-session)? This is the single most important question, because personalized data cached at a shared layer is a security incident, not a perf bug. Global data can be cached aggressively and far from the user. Personalized data must either skip shared caches entirely or be keyed by user.
2. How fresh does it actually need to be? Be honest, and push back on “real-time.” A stock ticker needs seconds. A product price needs minutes. A blog post or marketing copy can be hours or days stale and nobody notices. The acceptable staleness is your cache lifetime. Most data tolerates far more staleness than stakeholders claim.
3. How does it get invalidated? Time-based (it just expires) or event-based (a mutation must purge it)? Event-based is where tags earn their keep. Decide your tagging scheme early and make it *specific* — `post:id‘,‘user:{id}`, `user: id‘,‘user:{id}`, not one giant `”all”` tag you nuke on every write, which throws away every benefit of caching in the first place.
4. What’s the blast radius if it’s stale? A stale “related articles” sidebar is fine. A stale account balance or inventory count is a real problem. The cost of being wrong tells you how conservative to be. High blast radius → cache less, invalidate harder.
5. Which layer is the right layer? Cache global, slow-changing data as close to the user as you can (CDN / Full Route Cache). Cache per-user data on the server keyed by user, or stream it. Cache nothing for genuinely real-time data — use Suspense and accept the request-time cost.
From those five answers, a default posture falls out almost mechanically:
Debugging stale data, the disciplined way
When (not if) something serves stale data, resist the urge to disable caching everywhere. That’s how you turn a fast app into a slow one and learn nothing. Instead, walk the map:
- Reproduce in production conditions.
next devrenders dynamically and hides most caching. Usenext build && next start. Half of all "caching bugs" are really "I only tested in dev." - Walk the stack and locate the stale copy. Is it the browser (try incognito / hard refresh)? The CDN (check response headers for cache-hit markers)? The Router Cache (does a hard nav fix it but a soft nav doesn’t)? The server Data Cache? The origin? Find which drawer holds the old value.
- Fix the invalidation, not the caching. The bug is almost never “this layer caches.” It’s “this layer caches and nothing told it to refresh.” Add the missing tag, the missing
revalidate, the missingrouter.refresh().
Identify the layer holding the stale data rather than carpet-bombing every cache. That single habit will save you more time than any API knowledge.
The takeaway
The proliferation of caching layers in modern Next.js isn’t accidental complexity each layer solves a real problem at a real point in the request’s journey. But because they’re independent, default-on, and uncoordinated, the only way to stay sane is to decide your strategy before you build, not after it breaks.
You don’t need to memorize every directive. You need the map five zones, data can get stuck in any of them and the five questions you run every piece of data through. Do that at the start, write it down, and the difference is night and day: caching stops being the spooky layer that randomly serves wrong data, and becomes what it should be a deliberate, debuggable performance asset.
Cache on purpose. Invalidate on purpose. The rest is just APIs.









