nextjs · caching · tutorial

Router Cache Confusing You? Next.js Caching, Fixed

fetch cache, RSC cache, router cache, revalidate — four caches, four rules. The table that ends the confusion, with BoringPush notes.

Broadway in New York around 1885, crowded with telegraph and telephone wires
Broadway in New York around 1885, crowded with telegraph and telephone wires · Photo: Unknown author, public domain, via Wikimedia Commons

Four caches, one word "cache"

Struggling with router cache: a button calls an API, data changes, page shows the old row. Add Router needs better cache invalidation: revalidateTag runs, logs fetch, nothing updates. Route handlers caching despite no-store: cache: 'no-store' set, stale data anyway. Three threads, three different caches failing — all filed under "caching is broken."

The non-obvious truth, documented brutally in App Router RSC footguns in production: fetch({ cache: 'no-store' }) does NOT stop the RSC payload itself from being reused. You fixed one cache while staring at another.

The four caches and their one rule each

Symptom Guilty cache Rule
API updated, page stale after navigation Client router cache (30s staleTime) router.refresh() after mutations, or revalidatePath from the action
no-store fetch still returns old data in handlers Data cache semantics on route handlers Don't fight it — move the read into the server component, or export const revalidate = 0 on the handler
revalidateTag runs, page unchanged Full-route / RSC cache tag mismatch Tag the fetch with next: { tags: ['posts'] } AND revalidate the same tag — mismatched names silently no-op
Page reloads fully after 30s idle Router cache expiry (default 30s) Expected. Lengthen with staleTimes in newer Next, or accept refetch
force-dynamic page ignores revalidate You opted out of caching entirely Remove force-dynamic, use granular revalidate = 300 per route instead

Two patterns cover 90% of production pain. After any mutation, call revalidatePath('/posts') inside the server action — not in the client, not eventually.

And never scatter force-dynamic as a debugging reflex: it disables the caches whose knobs (revalidate, fetch options, unstable_cache for slow API hops) actually solve the problem, as the slow external API thread learned.

Where BoringPush helps is everything around the cache: local SQLite answers revalidations quickly, so cache misses cost little, and there is nothing cold to warm. Ship from anywhere on hardware where a miss is a rounding error, and caching becomes an optimization again instead of a load-bearing wall.

The 60-second triage ritual

Next stale report, run this order — it names the layer in under a minute:

  1. Same URL, hard reload. Fixed? Client router cache. Add router.refresh() post-mutation.
  2. Still stale? Check the fetch. Is the query tagged and is the tag revalidated with the identical string? Mismatched names are the #1 silent no-op.
  3. Still stale? Check the handler. force-dynamic present? Replace with route-level revalidate = 300 and re-test.
  4. Slow API behind it all? Wrap just that hop so one sluggish CMS doesn't poison every page:
import { unstable_cache } from "next/cache";

const getPosts = unstable_cache(
  async () => db.post.findMany({ orderBy: { createdAt: "desc" } }),
  ["posts-list"],
  { revalidate: 300, tags: ["posts"] }
);

One cached hop, one tag, one revalidation path. That pattern ends more "caching is broken" threads than any other single change.

When to stop caching and fix the query instead

Caching has a smell test: if the same page needs three stacked cache layers to feel fast, the query underneath is the problem, not the cache config. The usual suspects from the threads — unindexed ORDER BY createdAt, N+1 Prisma includes on list pages, full-table CMS pulls filtered in JavaScript — all survive every cache setting and rot quietly until the cache expires at the worst moment.

Before adding another layer, run the slow query raw: over 200ms on local SQLite means fix the index first (the repo's database notes cover the exact PRAGMAs and the EXPLAIN QUERY PLAN check). Cache fast queries to skip work; never cache slow queries to hide them.

That one discipline removes half the entries in any caching confusion thread — and the half that remains is usually a mismatched tag name.

FAQ

revalidatePath vs revalidateTag — which one?

Path for page-shaped invalidation ("this URL is stale"), tags for data-shaped ("all pages using posts are stale"). Tag every fetch you plan to revalidate, or tags silently do nothing.

Why does no-store not prevent stale UI?

It controls the fetch data cache, not the rendered RSC payload or the 30s client router cache. Stale UI after correct fetching = one of the other two.

Is force-dynamic ever right?

Rarely: truly per-request pages (live dashboards). Everywhere else it trades one stale bug for permanent slowness.

Prefer route-level revalidate.

How do I debug which cache served stale?

Open DevTools, throttle to Slow 3G, navigate: instant = router cache hit; spinner then stale = RSC/data staleness; full reload = expired or uncached. Fix the layer you just identified.

Does BoringPush change any caching semantics?

No — same Next.js, same rules. It changes the cost of a miss (0.1ms local reads) so you can cache less aggressively and debug calmly.

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading