Back/Module A-16 The Router Cache — Client-Side RSC Payload Cache Deep Dive
Module A-16·25 min read

The most misdiagnosed staleness bug in Next.js: prefetch entry types (full vs partial), staleTimes per entry type, why Server Actions + revalidatePath do not immediately update what the user sees, router.refresh() semantics vs router.push(), and the production debugging workflow for "I mutated data and the UI is stale."

A-16 — The Router Cache Deep Dive

Who this is for: Engineers who have hit the classic incident and want to understand it completely before it hits them in production again.

You call revalidatePath('/dashboard') in a Server Action after updating a user's subscription tier. The action returns success. The user is still on the page. Their subscription badge still shows "Free". You check the database — the update landed. You check the server logs — the cache was invalidated. The browser still shows the wrong tier.

This is the Router Cache.


What the Router Cache Actually Is

The Router Cache is a client-side, in-memory store that lives inside the React Router's internal state. It is not localStorage. It is not the browser's HTTP cache. It is not a Service Worker cache. It is React state that is allocated when the Next.js router initialises and deallocated on full page reload.

The Router Cache stores RSC payloads — React Flight Protocol data, the binary format that Server Components use to send their rendered output to the client. Each entry is keyed by pathname plus serialised search parameters.

The Router Cache is entirely separate from Next.js's three server-side caches:

CacheLocationScopeSurvives page reload?
Router CacheClient RAMPer-tabNo
Data CacheServer (filesystem/Redis)Across all requestsYes
Full Route CacheServer (filesystem)Across all requestsYes
Request MemoizationServer RAMPer-requestN/A

Confusion between these four caches causes the majority of Next.js caching bugs. The Router Cache is the one engineers know least about because it's invisible in DevTools until you know where to look. The Data Cache and Full Route Cache have extensive documentation. The Router Cache is where bugs actually happen in production.

When revalidatePath runs on the server, it invalidates the Data Cache and Full Route Cache entries for that path. It has no mechanism to reach across the network and invalidate the Router Cache in a user's browser. The server doesn't even know which users have that path cached. The invalidation signal stops at the server boundary.


How Router Cache Entries Get Populated

Three paths into the Router Cache:

1. Prefetch on hover or viewport entry

When a <Link> component enters the viewport or the user hovers over it, Next.js fires a prefetch request for the route. The response is stored in the Router Cache tagged as a prefetch entry.

What gets stored depends on whether the route is static or dynamic:

  • Static route: The full RSC payload is stored. Layout, page, all data.
  • Dynamic route: Only the static portion is stored. Specifically: the layout segment RSC. The dynamic page content (the parts that vary per request) is not prefetched. Next.js knows not to cache dynamic content it hasn't fetched with real request context.

This is important: on navigation to a dynamic route, even if there's a Router Cache entry, the page portion will be fetched fresh. The layout portion is served from cache.

2. Navigation

When you navigate to a route (via <Link> click, router.push(), router.replace()), the full RSC payload is fetched and stored as a "full" entry. This entry is distinct from a prefetch entry — it includes the complete page content.

3. Explicit prefetch

router.prefetch('/path') programmatically stores a prefetch entry. Useful for warming the cache before the user interacts with a <Link> that isn't in the viewport.

Entry Types and Default Staleness

Entry typeWhat's storedDefault staleTime
Static route prefetchFull RSC payload5 minutes
Dynamic route prefetchLayout RSC only30 seconds
Full navigation (dynamic route)Full RSC payload30 seconds
Full navigation (static route)Full RSC payload5 minutes

"Stale" means the entry is expired and Next.js will refetch on the next navigation. An entry being stale does not cause an immediate refetch — it causes a refetch the next time that route is navigated to.


staleTimes Configuration

You can override the default TTLs globally:

typescript
// next.config.ts import type { NextConfig } from 'next' const config: NextConfig = { experimental: { staleTimes: { dynamic: 30, // seconds before dynamic route entries expire (default: 30) static: 300, // seconds before static route entries expire (default: 300) } } } export default config

Setting dynamic: 0 disables the Router Cache for dynamic routes entirely. Every navigation to a dynamic route refetches the RSC payload from the server, bypassing the cache. Use this for applications where data freshness is non-negotiable: trading dashboards, live auction pages, real-time collaboration tools.

The cost: navigation feels slightly slower. With dynamic: 0, navigating between pages always requires a round-trip to the server before the new content renders. With caching, navigation is instant if a warm entry exists.

Setting dynamic: 0 does not affect static routes. Those continue to use the static TTL.


Why revalidatePath Doesn't Update What the User Sees

This is the incident from the module introduction. Walk through it step by step:

  1. User loads /dashboard. Next.js fetches the RSC payload. The Router Cache stores a full entry. The user sees their current subscription tier.

  2. User clicks "Upgrade to Pro". A Server Action runs:

    typescript
    'use server' export async function upgradeSubscription() { await db.users.update({ where: { id: session.user.id }, data: { tier: 'pro' } }) revalidatePath('/dashboard') }
  3. revalidatePath('/dashboard') executes on the server. The Data Cache entry for /dashboard is marked stale. The Full Route Cache entry is deleted. The Router Cache entry in the user's browser is untouched. The server has no mechanism to reach it.

  4. The Server Action returns. The user is still on /dashboard. The Router Cache entry is still valid according to its TTL (up to 30 seconds for dynamic routes). No refetch fires.

  5. The user's browser re-renders from the existing React tree. The subscription badge reads the prop that was passed when the component last rendered — "Free". Nothing triggered a re-render with new data.

This is not a bug. It is the correct behaviour of a client-side cache. The cache doesn't know the server told it to be stale. The fix is to tell the client explicitly:

typescript
// Client Component that calls the Server Action 'use client' import { useRouter } from 'next/navigation' import { upgradeSubscription } from '@/actions/subscription' export function UpgradeButton() { const router = useRouter() async function handleUpgrade() { await upgradeSubscription() // Server Action — invalidates server caches router.refresh() // Tells the client to refetch current route } return <button onClick={handleUpgrade}>Upgrade to Pro</button> }

router.refresh() deletes the Router Cache entry for the current route and triggers a fresh RSC fetch from the server. The component tree reconciles in place — no full page reload, no visible navigation, just fresh data flowing into the existing React tree.


router.refresh() — What It Actually Does

router.refresh() does three things:

  1. Marks the current route's Router Cache entry as stale and deletes it.
  2. Sends a fetch request to the server for the current route's RSC payload. This is a standard RSC fetch — the same request that happens on navigation.
  3. When the response arrives, React reconciles the new RSC payload with the existing client tree. Components that receive changed props re-render. Components that receive identical props do not.

It does not reload the page. JavaScript state in Client Components (form state, scroll position, open modals) is preserved across a router.refresh() call unless the refreshed server data changes props that affect them.

It does not affect other routes in the cache. Only the current route's entry is invalidated.

Comparison Table

MethodRouter Cache effectFull page reload?Use case
router.refresh()Deletes current route entryNoAfter mutation, need fresh server data
router.push('/path')Reads cached entry if freshNoNormal forward navigation
router.replace('/path')Reads cached entry if freshNoReplace current history entry
router.prefetch('/path')Warms a prefetch entryNoPre-warm cache before user interaction
window.location.reload()Wipes entire Router CacheYesNuclear option, use only as fallback
window.location.href = '...'Wipes entire Router CacheYesHard redirect

The right tool for "I just mutated data and need the UI to show the updated state" is always router.refresh(). Full page reload is always wrong for this use case — it destroys client state unnecessarily and creates a visible page flash.


The revalidateTag + router.refresh() Pattern

The cleanest pattern for Server Actions that mutate data and need the UI to update:

typescript
// lib/actions/profile.ts — Server Action 'use server' import { revalidateTag } from 'next/cache' import { auth } from '@/lib/auth' import { db } from '@/lib/db' export async function updateProfile(data: { name: string; bio: string }) { const session = await auth() if (!session?.user) throw new Error('Not authenticated') await db.users.update({ where: { id: session.user.id }, data, }) // Invalidate server caches tagged with 'user-profile' revalidateTag('user-profile') // DO NOT call router.refresh() here — Server Actions run on the server, // not in the browser. You cannot call router.refresh() from a Server Action. }
typescript
// components/ProfileForm.tsx — Client Component 'use client' import { useRouter } from 'next/navigation' import { updateProfile } from '@/lib/actions/profile' export function ProfileForm({ initialData }: { initialData: { name: string; bio: string } }) { const router = useRouter() async function handleSubmit(formData: FormData) { await updateProfile({ name: formData.get('name') as string, bio: formData.get('bio') as string, }) // After server caches are invalidated, pull fresh data into Router Cache router.refresh() } return ( <form action={handleSubmit}> <input name="name" defaultValue={initialData.name} /> <textarea name="bio" defaultValue={initialData.bio} /> <button type="submit">Save</button> </form> ) }

The Server Action handles server-side cache invalidation. The Client Component handles Router Cache invalidation. These are two separate responsibilities on two separate sides of the network boundary. Neither can do the other's job.

Why Not revalidatePath Instead of revalidateTag

revalidatePath('/profile') invalidates all server cache entries for that exact path. revalidateTag('user-profile') invalidates all server cache entries tagged with user-profile regardless of which path they belong to.

Prefer tags when the same data appears on multiple routes. A user's profile might appear on /profile, /settings/profile, and /dashboard. Tagging the fetch calls with user-profile and calling revalidateTag('user-profile') invalidates all three in one call. revalidatePath would require three separate calls.

Tag your fetch calls when you're using the Data Cache:

typescript
// Server Component that fetches user data async function getUserProfile(userId: string) { const res = await fetch(`/api/users/${userId}`, { next: { tags: ['user-profile'] } }) return res.json() }

Debugging Stale Router Cache in Production

When a user reports seeing stale data and you need to diagnose whether it's the Router Cache or a server-side issue, follow this workflow:

Step 1: Confirm it's client-side

Ask the user to hard-reload (Cmd+Shift+R / Ctrl+Shift+R). If the data updates after a hard reload but not after soft navigation, it's the Router Cache. Hard reload wipes the entire Router Cache. Soft refresh (F5 or browser refresh button) may or may not, depending on the browser.

Step 2: Identify the request pattern

Open DevTools → Network tab → filter by "Fetch/XHR". Navigate to the stale route. Look for a request to the route URL with either:

  • A RSC: 1 request header (older Next.js versions)
  • A ?_rsc=<hash> query parameter (current Next.js)

If no such request fires when you navigate to the route, the Router Cache served a cached entry without going to the network.

Step 3: Check the TTL

Look at a previous request to that URL (one that did fire). Check the Cache-Control response header. If it reads public, max-age=300, must-revalidate, the route is classified as static with a 5-minute TTL. If it's private, no-cache, it's dynamic.

Compare the timestamp of the last fetch against the TTL. If the TTL hasn't expired, the Router Cache is behaving correctly — the issue is that the TTL is too long for your use case, not that the cache is broken.

Step 4: Force a miss for testing

In DevTools console, you can force a Router Cache miss on the current route:

javascript
// Debug only — not for production code window.next.router.refresh()

If the data updates after this, the Router Cache was holding stale data. The fix is to call router.refresh() from your application code after mutations.

Step 5: Confirm the server cache was actually invalidated

If the data is still stale after router.refresh(), the problem is on the server side — revalidatePath or revalidateTag didn't run, or the Data Cache has a longer TTL than expected. Check your Server Action's execution in server logs and verify the revalidation call is being reached.


The prefetch={false} Escape Hatch

For routes where you never want a prefetch entry — pages that always show live data that should not be cached even briefly:

typescript
import Link from 'next/link' // No prefetch entry created — navigation always fetches fresh <Link href="/live-auction" prefetch={false}> View Auction </Link>

With prefetch={false}:

  • No prefetch request fires when the link enters the viewport or is hovered.
  • On navigation, Next.js always requests a fresh RSC payload from the server.
  • The response IS stored in the Router Cache after navigation (with the standard TTL). prefetch={false} only prevents the prefetch — it doesn't disable the Router Cache for that route entirely.

To prevent any Router Cache storage, set dynamic: 0 in staleTimes configuration or call router.refresh() before displaying time-sensitive data.

The tradeoff for prefetch={false}: navigation feels slightly slower. The user clicks the link and waits for a round-trip before the new content renders. With prefetching, that round-trip happens speculatively in the background before the user clicks, so navigation appears instant.

Reserve prefetch={false} for routes where showing stale data for even 30 seconds would be incorrect: auction bid counts, seat availability, real-time prices.


The Router Cache and Optimistic Updates

Optimistic updates and the Router Cache interact in a way that trips up most engineers the first time they combine them.

When you use useOptimistic to show a temporary state (e.g., marking a notification as read before the server confirms), the optimistic state exists in React component state — not in the Router Cache. On router.refresh(), the Router Cache entry is replaced with fresh server data, and the optimistic state is discarded. This is correct — the server data is now the source of truth.

The problem arises when router.refresh() is called while an optimistic update is still pending (the server hasn't responded yet). The refresh fetches the pre-mutation server state and replaces the optimistic state. The UI reverts.

The pattern to avoid this: call router.refresh() only in the onFinish/onSuccess handler after the mutation is confirmed, not concurrently with it.

typescript
async function handleAction() { startTransition(() => setOptimistic(newValue)) // show optimistic state await performMutation() // wait for confirmation router.refresh() // NOW replace with server truth }

Where We Go From Here

A-17 covers error architecture — the four Next.js error handling mechanisms, how errors propagate through the RSC tree, and why most engineers use the wrong mechanism for their error category. With A-16's Router Cache model in place, A-17's reset() behaviour (which triggers a Router Cache re-fetch) will make complete sense.

Discussion