Caching & Revalidation
The 4 Layers Every Next.js Developer Gets Wrong
LinkedIn Hook
You built a Next.js app. Everything works in development.
You deploy to production and suddenly your API data is stale. Users see yesterday's prices. Your dashboard shows cached numbers from three hours ago. You add
cache: 'no-store'everywhere and now your server bill triples.Here's the truth most Next.js developers learn the hard way: Next.js has FOUR separate caching layers, and they interact in ways that are not obvious. Request memoization, data cache, full route cache, router cache — each one has different rules, different lifetimes, and different opt-out mechanisms.
Understanding these layers is the difference between a fast, cost-efficient app and a caching nightmare where you're either serving stale data or paying for unnecessary re-renders.
In this lesson, I break down all 4 layers with diagrams, show you exactly when each one helps and when it hurts, and give you the mental model interviewers expect.
Read the full lesson -> [link]
#NextJS #React #WebDevelopment #InterviewPrep #Caching #Frontend #CodingInterview #Performance #100DaysOfCode
What You'll Learn
- The 4 caching layers in Next.js and what each one caches
- How request memoization deduplicates identical fetch calls within a single render
- How the data cache persists fetch responses across requests and deployments
- How the full route cache stores pre-rendered HTML and RSC payloads
- How the router cache keeps previously visited routes in memory on the client
- The difference between
cache: 'no-store'andrevalidate: 0(and why it matters) - How cache tags give you surgical control over invalidation
- When caching hurts and how to opt out of each layer
The Warehouse Analogy — Understanding 4 Layers of Caching
Imagine you run an online bookstore with a physical warehouse.
Layer 1 — Your desk notepad (Request Memoization): While processing a single customer order, you look up the same book's price three times — once for the receipt, once for the invoice, once for the shipping label. Instead of walking to the catalog each time, you jot the price on a sticky note on your desk. When the order is done, you throw the sticky note away. This is request memoization — it deduplicates identical lookups within a single request, and it disappears when the request ends.
Layer 2 — The catalog binder (Data Cache): Your warehouse keeps a printed catalog binder. When someone asks for a book's price, you check the binder first. The binder is updated periodically or when someone explicitly says "reprint page 47." This is the data cache — it stores fetched data across requests, surviving until revalidated or explicitly purged.
Layer 3 — Pre-packed boxes (Full Route Cache): For your most popular book bundles, you pre-pack the boxes at the start of the day. When an order comes in, you grab the pre-packed box and ship it immediately — no assembling required. This is the full route cache — Next.js pre-renders entire pages (HTML + RSC payload) and serves them instantly without re-executing your component code.
Layer 4 — The customer's memory (Router Cache): When a customer browses your website, their browser remembers the pages they've already visited. If they click "Back" to a page they saw 30 seconds ago, there's no network request — their browser shows the cached version instantly. This is the router cache — it lives in the browser and caches previously visited routes during a session.
Each layer is independent. Each has its own lifetime. Each has its own opt-out mechanism. The request flows through them like water through filters — and understanding the order is what interviewers test.
Layer 1: Request Memoization
Request memoization is the simplest and most misunderstood layer. It deduplicates fetch calls with the same URL and options within a single server-side render pass.
Why It Exists
In a component tree, multiple components often need the same data. Without memoization, you'd either:
- Fetch in a parent and drill props down (tight coupling)
- Make the same API call 5 times from 5 different components (wasteful)
Request memoization lets each component fetch independently — Next.js collapses duplicate calls into one.
How It Works
// app/products/[id]/page.tsx
// This component fetches the product
async function ProductPage({ params }: { params: { id: string } }) {
// FETCH #1 — hits the API
const product = await fetch(`https://api.store.com/products/${params.id}`);
const data = await product.json();
return (
<main>
<h1>{data.name}</h1>
<ProductPrice id={params.id} />
<ProductReviews id={params.id} />
</main>
);
}
// This component ALSO fetches the product (same URL, same options)
async function ProductPrice({ id }: { id: string }) {
// FETCH #2 — DOES NOT hit the API. Returns memoized result from FETCH #1.
const product = await fetch(`https://api.store.com/products/${id}`);
const data = await product.json();
return <span className="price">${data.price}</span>;
}
// This component ALSO fetches the product
async function ProductReviews({ id }: { id: string }) {
// FETCH #3 — Also memoized. Still only 1 actual API call total.
const product = await fetch(`https://api.store.com/products/${id}`);
const data = await product.json();
return <div>{data.reviews.length} reviews</div>;
}
// Result: 3 fetch calls in code, but only 1 actual network request.
// The memoization is automatic — no configuration needed.
Key Rules
REQUEST MEMOIZATION RULES:
- Only works with fetch() — not axios, not database queries
- Only during a single server render (not across requests)
- Only matches when URL AND options are identical
- Automatically cleared when the render completes
- Works in Server Components, Route Handlers, and generateMetadata
- Does NOT work in Client Components (they run in the browser)
Extending Memoization to Non-Fetch Functions
For database queries or other non-fetch functions, use React's cache():
// lib/data.ts
import { cache } from "react";
import { db } from "./db";
// Wrap your database query with React's cache()
// Now identical calls within the same render are deduplicated
export const getUser = cache(async (id: string) => {
// This only executes once per render, even if called from 5 components
const user = await db.user.findUnique({ where: { id } });
return user;
});
// Component A
async function UserHeader({ userId }: { userId: string }) {
const user = await getUser(userId); // Executes the query
return <h1>{user.name}</h1>;
}
// Component B
async function UserSidebar({ userId }: { userId: string }) {
const user = await getUser(userId); // Returns memoized result — no second query
return <aside>{user.email}</aside>;
}
Layer 2: Data Cache
The data cache is where Next.js stores the results of fetch calls on the server. Unlike request memoization (which lasts one render), the data cache persists across multiple requests and even across deployments.
How It Works
REQUEST FLOW THROUGH THE DATA CACHE:
Component calls fetch(url)
│
▼
┌─────────────────┐ HIT ┌──────────────┐
│ Is there a │────────────▶│ Return │
│ cached response? │ │ cached data │
└─────────────────┘ └──────────────┘
│ MISS
▼
┌─────────────────┐
│ Fetch from │
│ data source │
└─────────────────┘
│
▼
┌─────────────────┐
│ Store in data │
│ cache │
└─────────────────┘
│
▼
┌─────────────────┐
│ Return fresh │
│ data │
└─────────────────┘
Default Behavior
By default in Next.js 14+, fetch() in Server Components caches responses indefinitely. This catches many developers off guard:
// This fetch is cached FOREVER by default (until revalidated or purged)
const res = await fetch("https://api.example.com/data");
// The same data is returned for every user, every request, forever
// unless you explicitly opt out or set revalidation
Controlling the Data Cache
// OPTION 1: Time-based revalidation
// Cache for 60 seconds, then refresh in background
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// OPTION 2: Opt out completely — never cache
const res = await fetch("https://api.example.com/products", {
cache: "no-store",
});
// OPTION 3: Tag-based invalidation
// Cache until you manually call revalidateTag("products")
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});
// OPTION 4: Page-level opt out
// In your page.tsx or layout.tsx:
export const dynamic = "force-dynamic"; // All fetches on this page skip cache
What Persists and What Doesn't
| Aspect | Persists Across Requests? | Persists Across Deployments? |
|---|---|---|
| Request Memoization | No (single render only) | No |
| Data Cache | Yes | Yes (on Vercel) |
This is why stale data is such a common production bug. The data cache survives deployments on platforms like Vercel. Your code changes, but the cached API responses from the previous deployment are still being served.
Layer 3: Full Route Cache
The full route cache stores the pre-rendered output of entire routes — both the HTML and the React Server Component (RSC) payload. This is what makes static pages instant.
How It Works
FULL ROUTE CACHE:
At build time (or first request for dynamic routes):
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Execute │────▶│ Render │────▶│ Store in │
│ Server │ │ to HTML + │ │ Full Route │
│ Components │ │ RSC Payload │ │ Cache │
└──────────────┘ └──────────────┘ └──────────────┘
On subsequent requests:
┌──────────────┐ ┌──────────────┐
│ Request for │────▶│ Serve cached │
│ /products │ │ HTML + RSC │
└──────────────┘ │ (instant) │
└──────────────┘
No component execution. No data fetching. Pure cache hit.
When It Applies
The full route cache only works for statically rendered routes. A route is static when:
- It has no dynamic functions (
cookies(),headers(),searchParams) - All fetch calls are cached (no
cache: 'no-store') - The page does not export
dynamic = 'force-dynamic'
// STATIC — gets full route cached
// No dynamic functions, all fetches cached
export default async function ProductsPage() {
const products = await fetch("https://api.store.com/products", {
next: { revalidate: 3600 },
});
const data = await products.json();
return <ProductList products={data} />;
}
// DYNAMIC — NOT full route cached
// Uses cookies(), which is a dynamic function
import { cookies } from "next/headers";
export default async function DashboardPage() {
const cookieStore = cookies();
const token = cookieStore.get("session");
const user = await fetch("https://api.store.com/me", {
headers: { Authorization: `Bearer ${token?.value}` },
cache: "no-store",
});
return <Dashboard user={await user.json()} />;
}
Relationship to the Data Cache
The full route cache depends on the data cache. When a data cache entry is revalidated, the full route cache for that page is also invalidated:
Data Cache invalidated (revalidateTag or time-based)
│
▼
Full Route Cache for affected pages is purged
│
▼
Next request triggers fresh render + new cache entries
Layer 4: Router Cache (Client-Side)
The router cache is the only caching layer that lives in the browser. It stores previously visited routes in memory during the user's session so that back/forward navigation and revisiting pages is instant.
How It Works
// User visits /products — server sends HTML + RSC payload
// Router cache stores the RSC payload in browser memory
// User navigates to /products/123
// Router cache stores this page too
// User clicks "Back" to /products
// NO network request — router cache serves the stored version instantly
// User refreshes the page (Cmd+R / F5)
// Router cache is cleared — fresh request to the server
Cache Duration
The router cache has different lifetimes depending on how the route was rendered:
ROUTER CACHE DURATION:
Static routes: cached for 5 minutes (default)
Dynamic routes: cached for 30 seconds (default)
These are configurable in next.config.js (Next.js 14.2+):
// next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 0, // Don't cache dynamic pages in router cache
static: 180, // Cache static pages for 3 minutes
},
},
};
When the Router Cache Causes Problems
The router cache is the most surprising source of "stale data" bugs in Next.js apps:
// Scenario: User submits a form that updates data
// 1. User is on /products (router cache stores this page)
// 2. User navigates to /products/edit and updates a product name
// 3. User navigates back to /products
// 4. BUG: /products shows the OLD product name (served from router cache!)
// Solution: Use revalidatePath in your Server Action
"use server";
import { revalidatePath } from "next/cache";
export async function updateProduct(id: string, name: string) {
await db.product.update({ where: { id }, data: { name } });
// This invalidates the server-side caches AND tells the router
// to refetch /products on the next navigation
revalidatePath("/products");
}
cache: 'no-store' vs revalidate: 0 — The Subtle Difference
This is a classic interview question. Functionally, they produce the same result — neither caches data. But they communicate different intent:
// OPTION A: cache: 'no-store'
// "This data should NEVER be cached. Period."
const res = await fetch("https://api.example.com/user/me", {
cache: "no-store",
});
// OPTION B: revalidate: 0
// "Cache this, but revalidate immediately (effectively no cache)."
const res = await fetch("https://api.example.com/user/me", {
next: { revalidate: 0 },
});
// Both skip the data cache and fetch fresh on every request.
// Both make the route dynamic (opt out of full route cache).
//
// Practical difference:
// - cache: 'no-store' is the Web Fetch API standard option
// - revalidate: 0 is Next.js-specific
// - In Next.js 15, the default changed: fetch() is NO LONGER cached by default
// (reversing the Next.js 14 behavior where everything was cached by default)
Page-Level Dynamic Opt-Out
You can also force an entire page to be dynamic without modifying individual fetch calls:
// app/dashboard/page.tsx
// Every fetch on this page bypasses the data cache
// The page bypasses the full route cache
export const dynamic = "force-dynamic";
// Alternative: specific revalidation for the whole page
export const revalidate = 0; // Equivalent to force-dynamic
export default async function Dashboard() {
// All these fetches are dynamic — no caching
const stats = await fetch("https://api.example.com/stats");
const alerts = await fetch("https://api.example.com/alerts");
const users = await fetch("https://api.example.com/users");
// ...render dashboard
}
Cache Tags — Surgical Invalidation
Cache tags are the most powerful caching tool in Next.js. They let you group related data across different pages and invalidate them all with a single function call.
Tagging Fetches
// app/products/page.tsx — Product listing page
async function getProducts() {
const res = await fetch("https://api.store.com/products", {
next: { tags: ["products", "store-data"] },
});
return res.json();
}
// app/products/[id]/page.tsx — Individual product page
async function getProduct(id: string) {
const res = await fetch(`https://api.store.com/products/${id}`, {
next: { tags: [`product-${id}`, "products", "store-data"] },
});
return res.json();
}
// app/categories/page.tsx — Category page (also shows products)
async function getCategories() {
const res = await fetch("https://api.store.com/categories", {
next: { tags: ["categories", "store-data"] },
});
return res.json();
}
Invalidating by Tag
// app/admin/actions.ts
"use server";
import { revalidateTag } from "next/cache";
// Update a single product — only that product's pages refresh
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data });
revalidateTag(`product-${id}`);
// Affects: /products/[id] page only
}
// Add a new product — all product listings refresh
export async function addProduct(data: ProductData) {
await db.product.create({ data });
revalidateTag("products");
// Affects: /products, /products/[id] for ALL products, /categories (if tagged)
}
// Full store data refresh — nuclear option
export async function refreshStoreData() {
revalidateTag("store-data");
// Affects: EVERY page that fetches any store data
}
Tag Hierarchy Pattern
TAG HIERARCHY — Design your tags like a tree:
"store-data" (invalidates everything)
├── "products" (invalidates all product pages)
│ ├── "product-1" (invalidates product 1 only)
│ ├── "product-2" (invalidates product 2 only)
│ └── "product-3" (invalidates product 3 only)
├── "categories" (invalidates all category pages)
│ ├── "category-electronics"
│ └── "category-books"
└── "store-settings" (invalidates store config)
Each fetch can have multiple tags. Invalidating ANY matching tag
purges that cached entry. Design tags from broad to specific.
When Caching Hurts — The Anti-Patterns
Caching is not always beneficial. Here are scenarios where Next.js caching works against you:
1. User-Specific Data in Cached Routes
// WRONG: This page is cached and shared across ALL users
export default async function ProfilePage() {
// This response is cached in the data cache — every user sees the same profile!
const user = await fetch("https://api.example.com/me");
return <Profile user={await user.json()} />;
}
// RIGHT: Opt out of caching for user-specific data
export default async function ProfilePage() {
const user = await fetch("https://api.example.com/me", {
cache: "no-store", // Each request fetches the current user's data
headers: { Authorization: `Bearer ${getToken()}` },
});
return <Profile user={await user.json()} />;
}
2. Over-Caching During Development
// Development vs Production caching behavior:
//
// In development (next dev):
// - Data cache is disabled by default (fetches are always fresh)
// - Full route cache is disabled
// - Request memoization still works
// - Router cache still works
//
// In production (next build + next start):
// - ALL caching layers are active by default
// - This is why "it works in dev but not in prod" is so common
//
// TIP: Test with `next build && next start` before deploying
// to catch caching issues early
3. Stale Data After Mutations Without Revalidation
// WRONG: Data updated but cache not invalidated
"use server";
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } });
// Forgot to revalidate! The product still appears on cached pages.
}
// RIGHT: Always revalidate after mutations
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } });
// Invalidate the specific product and the listing page
revalidateTag(`product-${id}`);
revalidatePath("/products");
}
Common Mistakes
-
Assuming
fetch()is always fresh in production. In Next.js 14,fetch()caches by default. Many developers deploy and wonder why their API data is frozen. Always explicitly set caching behavior: eithercache: 'no-store'for dynamic data ornext: { revalidate: N }for time-based freshness. Note: Next.js 15 changed this default —fetch()is no longer cached by default. -
Forgetting the router cache exists. You revalidate your server-side data cache with
revalidateTag, but the user still sees stale data because the router cache in their browser is serving the old version. Userouter.refresh()on the client side or ensure your Server Action callsrevalidatePathto signal the client to refetch. -
Using
cache: 'no-store'on every fetch "just to be safe." This disables all server-side caching, turns every route dynamic, and makes your server handle the full rendering cost on every single request. Your server costs spike and response times increase. Only opt out of caching where you genuinely need fresh data — cached responses are free and instant. -
Not understanding that the shortest revalidation wins. When a page has multiple fetches with different
revalidatevalues, the shortest one determines the page's revalidation interval. If one fetch saysrevalidate: 3600and another saysrevalidate: 10, the entire page revalidates every 10 seconds.
Interview Questions
Q1: What are the 4 caching layers in Next.js? Explain each briefly.
Answer: (1) Request Memoization — deduplicates identical
fetch()calls within a single server render pass; cleared after the render completes. (2) Data Cache — stores fetch responses on the server across requests and deployments; persists until revalidated with time-based (revalidate: N) or on-demand (revalidateTag/revalidatePath) strategies. (3) Full Route Cache — stores pre-rendered HTML and RSC payloads for static routes; bypassed when any fetch usescache: 'no-store'or the page uses dynamic functions likecookies(). (4) Router Cache — a client-side in-memory cache storing RSC payloads of visited routes; enables instant back/forward navigation; lasts 30 seconds for dynamic routes and 5 minutes for static routes by default.
Q2: What is the difference between cache: 'no-store' and revalidate: 0?
(Covered in the cache: 'no-store' vs revalidate: 0 section above.)
Q3: A user updates their profile via a Server Action, navigates away, then navigates back — but they see old data. What's happening and how do you fix it?
Answer: The router cache is serving the old page from browser memory. Even though the Server Action updated the database and possibly invalidated the server-side data cache, the router cache on the client still holds the previously rendered version. The fix is to call
revalidatePath('/profile')inside the Server Action, which invalidates both the server-side caches and signals the router cache to refetch the page on next navigation. Alternatively, callrouter.refresh()on the client to force the router to refetch all visible routes.
Q4: How do cache tags work and when would you use them over revalidatePath?
(Covered in the Cache Tags section above.)
Q5: Why might an app work correctly in development but show stale data in production?
Answer: In development (
next dev), the data cache and full route cache are disabled by default — every fetch hits the origin server. In production (next build+next start), all caching layers are active. Fetches without explicitcache: 'no-store'orrevalidateoptions are cached indefinitely (in Next.js 14). Developers who never set caching options during development deploy to production and discover their pages serve frozen data from build time. The fix is to always explicitly define caching behavior and test withnext build && next startlocally before deploying.
Quick Reference -- Cheat Sheet
+----------------------------------------------------------------------+
| NEXT.JS 4 CACHING LAYERS — CHEAT SHEET |
+----------------------------------------------------------------------+
| Layer | Where | Duration | Opt Out |
|---------------------|---------| ------------------|---------------------------|
| Request Memoization | Server | Single render | Use AbortController |
| | | | or different URL/options |
| Data Cache | Server | Persistent | cache: 'no-store' |
| | | (across deploys) | revalidate: 0 |
| | | | revalidateTag/Path |
| Full Route Cache | Server | Until revalidated | dynamic = 'force-dynamic' |
| | | or redeployed | Use dynamic functions |
| | | | (cookies, headers) |
| Router Cache | Client | 30s (dynamic) | router.refresh() |
| | | 5min (static) | revalidatePath in action |
| | | | staleTimes config |
+----------------------------------------------------------------------+
REQUEST FLOW:
Browser → Router Cache → Full Route Cache → Data Cache → Request Memoization → Origin
INVALIDATION METHODS:
revalidateTag("tag") → Data Cache + Full Route Cache
revalidatePath("/path") → Data Cache + Full Route Cache + Router Cache
router.refresh() → Router Cache only
Time-based (revalidate) → Data Cache → triggers Full Route Cache rebuild
DYNAMIC ESCAPE HATCHES:
export const dynamic = "force-dynamic" → page level
export const revalidate = 0 → page level
fetch(url, { cache: "no-store" }) → fetch level
cookies() / headers() / searchParams → implicit dynamic
NEXT.JS 14 vs 15:
v14: fetch() cached by default (opt out with no-store)
v15: fetch() NOT cached by default (opt in with cache: 'force-cache')
+----------------------------------------------------------------------+
Previous: Lesson 4.1 -- Fetching in Server Components -> Next: Lesson 4.3 -- Loading & Error States ->
This is Lesson 4.2 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.