Next.js Interview Prep
Data Fetching

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


Caching & Revalidation thumbnail


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' and revalidate: 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:

  1. Fetch in a parent and drill props down (tight coupling)
  2. 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

AspectPersists Across Requests?Persists Across Deployments?
Request MemoizationNo (single render only)No
Data CacheYesYes (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.

Caching & Revalidation visual 1


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: either cache: 'no-store' for dynamic data or next: { 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. Use router.refresh() on the client side or ensure your Server Action calls revalidatePath to 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 revalidate values, the shortest one determines the page's revalidation interval. If one fetch says revalidate: 3600 and another says revalidate: 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 uses cache: 'no-store' or the page uses dynamic functions like cookies(). (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, call router.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 explicit cache: 'no-store' or revalidate options 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 with next build && next start locally 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.

On this page