ML
Next.js

Caching in Next.js 15+: The Four Layers

Next.js has four overlapping caches. Most production bugs are misalignment between them — here's the map.

May 05, 20269 min readNext.jsPerformance

"Why is my page still showing yesterday's data?" is the most-asked Next.js question. The answer is almost always: one of four caches is in the way. Understanding which one — and which lever flushes it — is the whole game.

1. The four layers

  1. Request Memoization — per-request, in-memory. Dedupes identical fetch() calls within a single render.
  2. Data Cache — persistent across requests and deploys. Caches fetch() responses on the server.
  3. Full Route Cache — the rendered HTML and the RSC payload, per route.
  4. Router Cache — client-side, in-memory. Caches navigation results so back-button is instant.

They cascade: a request first hits the Router Cache (in the browser), then the Full Route Cache (on the server's CDN/build artefacts), then the Data Cache, then the actual origin.

2. Request Memoization — fix-and-forget

If you call fetch("/api/user") three times in the same render, Next runs it once. This is automatic and de-duplicates within one render only. You almost never need to think about it. Good to know it exists so you stop manually passing data down the tree to "avoid double fetches."

3. Data Cache — where most production bugs live

By default in 15+, fetch() is uncached unless you opt in. Opt-in shapes:

// Force-cache, no revalidation
const res = await fetch(url, { cache: "force-cache" });

// Time-based revalidation
const res = await fetch(url, { next: { revalidate: 3600 } });

// Tag-based, manual invalidation
const res = await fetch(url, { next: { tags: ["products"] } });

And the invalidation tool:

"use server";
import { revalidateTag, revalidatePath } from "next/cache";

export async function publishProduct(id: string) {
  await db.product.publish(id);
  revalidateTag("products");   // re-fetches anything tagged "products"
}

The mental model: tag your fetches by domain ("products," "user:42"), invalidate by tag in your server actions. Path-based invalidation works but is coarser.

4. Full Route Cache — static vs dynamic

If a route has no dynamic functions (no cookies(), no headers(), no uncached fetch()), it is statically generated at build time and served from the route cache forever (until you redeploy or invalidate).

The moment you call cookies() or an uncached fetch(), the route opts out of static generation and renders dynamically every request. This is usually what you want for personalised content but is the root cause of "I deployed but my static page never changes" — you are not actually dynamic, you are cached.

5. Router Cache — the silent one

When you click a <Link> and navigate, Next caches the result in the browser. Hit the back button: it's instant, served from memory.

This cache is invisible to anything you can do on the server. revalidateTag does not touch it. The user can sit on a stale view for the cache's TTL (5 minutes for layouts, 30 seconds for pages by default).

To force a fresh navigation, use router.refresh() — it bypasses the router cache for the current route.

6. The decision tree

  • "My data is stale after I update it" → Data Cache. Call revalidateTag in the server action that updated.
  • "My page is the same for every user when it shouldn't be" → Full Route Cache. You forgot to call cookies() or use noStore() inside the page.
  • "Back button shows old data" → Router Cache. router.refresh() or accept it for the cache TTL.
  • "My deploy invalidated everything" → Full Route Cache on a new build is wiped, by design.

7. The opt-out hierarchy, fastest to slowest

  1. Page-level: export const dynamic = "force-dynamic" — opts the whole route out of all server caches.
  2. Fetch-level: fetch(url, { cache: "no-store" }) — opts that single fetch out.
  3. Function-level: noStore() inside the component — opts the component out of static rendering.

Use the smallest scope that solves your problem. force-dynamic on a page is a sledgehammer that breaks performance for the 95% of the page that could have been static.

The honest summary

Next.js caching is powerful and unforgiving. The teams I see succeed write down — actually write down — which cache layer governs each surface in their app, and which event invalidates it. The teams that get burned add "force-dynamic" everywhere and wonder why their app is slow.

SharePostLinkedIn

Reader Discussion

2 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Isabella Costa· Junior EngineerKind words

    saved this. sharing at standup tomorrow — we've had exactly this problem for 2 sprints and nobody on the team had framed it this way 🙏

    May 07, 2026·2 days later
  2. Kenta Yamada· Tech LeadAsks

    would love a war-story follow-up. principles are clear; the actual debugging session is where the interesting stuff lives. there's a real shortage of "here's the dashboard, here's the thread we pulled, here's where we got stuck for 90 mins" content.

    May 09, 2026·4 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email