Skip to content
// booting portfolio
the engineer's notebookYash.
loading assets000%
Yash.
All writing
Next.jsCachingReactPerformance

A Mental Model for Next.js App Router Caching

7 min read

When I moved a project to the App Router, the part that broke my brain wasn't server components — it was caching. Data that should have been fresh was stale; data that should have been cached refetched on every request. The fix wasn't a magic flag, it was a mental model. There are four caches, and they each answer a different question.

The four caches

  1. Request Memoization — within a single render, identical fetch calls are deduped. Scope: one request.
  2. Data Cache — persists fetch results across requests and deployments. Scope: the server, until revalidated.
  3. Full Route Cache — caches the rendered HTML/RSC payload of static routes at build time. Scope: the route.
  4. Router Cache — the client keeps visited route payloads in memory for snappy back/forward. Scope: the user's session.

Most "why is this stale" questions are really "which of these four is holding the old value?"

fetch is the control surface

The Data Cache is driven by options you pass to fetch:

// cached forever until revalidated (default for static)
await fetch(url);

// revalidate at most every 60s
await fetch(url, { next: { revalidate: 60 } });

// never cache — always hit the source
await fetch(url, { cache: 'no-store' });

The trap: if any fetch in a route opts into no-store or you read dynamic things like cookies() or headers(), the whole route becomes dynamic and the Full Route Cache no longer applies. Dynamic is contagious.

Not everything goes through fetch

This is the bit that bit me. If you query a database with Mongoose or call a non-fetch client, none of the fetch caching applies — you opted out without realizing. Wrap those in unstable_cache to get into the Data Cache:

import { unstable_cache } from 'next/cache';

const getPosts = unstable_cache(
  async () => db.post.find().lean(),
  ['posts'],
  { tags: ['posts'], revalidate: 3600 }
);

Now your DB reads are cached and, crucially, taggable.

Tags make invalidation sane

Time-based revalidation is fine for things that drift slowly. But when a user publishes a post, you don't want to wait out a timer. Tag your reads, then bust the tag on write:

import { revalidateTag } from 'next/cache';

export async function publishPost(data: PostInput) {
  await db.post.create(data);
  revalidateTag('posts'); // every read tagged 'posts' is now stale
}

This is the cache-aside pattern, but Next manages the store for you. Reads populate by tag; writes invalidate by tag.

The client cache is the sneaky one

Even with the server sorted, users can see stale data because the Router Cache served an in-memory payload. After a mutation in a Server Action, nudge the client too:

import { revalidatePath } from 'next/cache';

revalidatePath('/blog'); // refresh the route's client cache

The model, in one sentence

Memoization dedupes within a render, the Data Cache persists across renders, the Full Route Cache stores whole static pages, and the Router Cache lives in the browser. When something is stale or uncached, name which one is responsible before you reach for a flag — that single habit fixed nearly every caching bug I had.

Comments (0)

  • Be the first to comment.