For roughly twelve months — from Next.js 13.4 through the 14.x cycle — thousands of developers investigating the Next.js router cache hit a wall they couldn’t explain. In fact, their server logs showed fresh data, but their browser UI remained static. revalidatePath returned without error, and force-dynamic was set. However, the UI kept showing stale state. Consequently, the cache wasn’t expiring when it should. Worse, it seemed to reset its own timer whenever the user navigated. In our experience, this wasn’t a beginner misunderstanding. Rather, it was a framework-level architectural decision that, in hindsight, compromised data correctness in the name of perceived performance.
- The Next.js Client-Side Router Cache stored RSC payloads in browser heap memory with hardcoded 30-second (dynamic) and 5-minute (static) TTLs in v13.4–14.1, with no developer override.
- A “Reset on Navigation” bug meant active users could keep stale data alive indefinitely — mathematically preventing cache expiration.
- The
staleTimesconfig introduced in v14.2 confirmed the values were locked: Vercel acknowledged the flaw by makingdynamic: 0the correct default for real-time apps. - Next.js 15 changed the default router cache for dynamic segments to 0 seconds, validating the community’s year-long pushback.
What Was the Next.js Client-Side Router Cache?
In Next.js App Router applications, client-side navigation doesn’t trigger standard document requests. Specifically, the Client-Side Router Cache refers to an in-memory repository for React Server Component (RSC) payloads that Next.js stores in the browser’s heap memory. An RSC payload is a serialized JSON representation of the server components, distinct from HTML, containing the data and instructions needed to reconcile the Virtual DOM. When a valid entry exists, the router renders from cache and never contacts the server.
In contrast, this is different from the browser’s HTTP Disk Cache and different from the server-side Full Route Cache. Therefore, it is a third layer operating on its own rules — and in v13.4 through 14.1, those rules were hardcoded and invisible to developers.
The critical forensic finding is that this cache layer operated independently of the server’s state once the RSC payload was acquired. Unlike a standard fetch request that respects Cache-Control: no-store headers, the router’s decision to serve from cache was governed entirely by client-side JavaScript heuristics. The server could revalidate a page via ISR, regenerate fresh HTML, and log a fresh render — and the client would still show the 30-second-old payload, because it simply didn’t ask.
What Were the 7 Failure Patterns in Next.js v13.4–14.x?
Notably, our analysis of regression reports, issue trackers (including GitHub issues #54173, #58909, #61184), and framework deep-dive discussions in the Next.js repository reveals seven distinct failure patterns during the 13.4–14.x era. In particular, even server-side caching mechanisms like Incremental Static Regeneration (ISR) clashed with the client-side router cache.
- The “Reset on Navigation” infinite stale loop: The 30-second timer reset on every interaction, allowing active users to trap themselves in stale data indefinitely.
- Split-brain in Parallel and Intercepting Routes:
revalidatePathsuccessfully cleared the server cache but failed to propagate invalidation to intercepted modal slots (documented in GitHub issue #54173). force-dynamicignoring the client cache: Pages markeddynamic = 'force-dynamic'were re-rendered fresh on the server — but the client router still served the 30-second-old payload without asking.- ISR overridden by prefetch TTL: A page configured with
revalidate = 60(1 minute) was served stale for up to 5 minutes because a<Link>prefetch committed the payload to the client cache before the ISR window elapsed. router.refresh()feedback loops: Developers placingrouter.refresh()insideuseEffectto combat staleness inadvertently triggered infinite re-render cycles, effectively DDoSing their own backends.- The 404 “death spiral”: Not-found pages that triggered any on-mount refresh logic entered infinite error-boundary loop cycles.
- The Two Round-Trip anti-pattern: Mutations via Route Handlers (API endpoints) required a separate
router.refresh()call to make the result visible — because unlike Server Actions, API calls returned no signal the client router could interpret.
How Did the 30-Second Heuristic Actually Work?
In Next.js v13.4 through 14.1, dynamic routes (those using cookies(), headers(), or no-store fetch) were assigned a hardcoded 30-second in-memory TTL. Static prefetched routes received 5 minutes (300 seconds). Back/Forward navigation received indefinite (session-length) retention.
Indeed, Next.js did not expose these values as configuration options. Instead, the framework used these internal heuristics to emulate the browser’s native Back/Forward Cache (bfcache) experience — preserving scroll positions and layout states during rapid navigation. As a goal for static sites, this is reasonable. However, for real-time applications, it is catastrophic.
Specifically, the reset logic of the 30-second timer made it particularly dangerous. Let’s trace how the mechanism operated:
- T=0s: User visits
/dashboard. The router fetches and caches the RSC payload. - T=15s: User navigates to
/settings. - T=25s: User navigates back to
/dashboard. Cache is 25s old — within window. Consequently, the router serves the cached payload and resets the timer to T=25s. - T=50s: User navigates away.
- T=54s: User returns. Cache was last active at T=25s. Elapsed: 29s — still within window. Served from cache again.
This mechanism effectively created a “Zeno’s Paradox of caching”: as long as a user interacted with a route more than once every 30 seconds, the cache never expired. A user repeatedly checking a ticket status (“Pending” to “Approved”) would unwittingly keep the stale “Pending” state alive forever.
Why Didn’t force-dynamic Fix It?
One of the most damaging documentation failures of this era involved export const dynamic = 'force-dynamic'. Developers encountering stale data reached for this directive as the logical escape hatch. The documentation stated that force-dynamic opted out of the Full Route Cache (server-side) and the Data Cache (fetch). But it said nothing about the client router cache.
The forensic reality: force-dynamic controlled the supplier (the server) — ensuring a fresh render on every request — but the consumer (the client router) had its own rules. A user navigating to a force-dynamic page within 10 seconds of their previous visit would see the 10-second-old RSC payload. The server was generating fresh HTML on every request. Nobody was asking for it.
This specific failure — a page explicitly marked dynamic still behaving statically on the client — was a primary source of lost trust in the framework.
What Hacks Did Developers Use to Bypass the Router Cache?
The intensity of the community’s struggle is best measured by the hacks it produced. These aren’t embarrassing edge cases. In fact, they are forensic proof that official APIs were insufficient for common use cases.
The Timestamp Hack
// The Universal Bypass — adopted across thousands of Next.js codebases
router.push(`/dashboard?t=${Date.now()}`);By appending a unique, ever-changing query parameter, developers forced the router to treat each navigation as a new route, creating a fresh cache entry. Therefore, this workaround was highly effective — however, it was also catastrophic for memory. Every navigation created a unique entry in the router’s heap store. Consequently, in long-lived SPA sessions, this created unbounded memory growth. The very “deduplication” the router was designed for was being actively subverted.
The Middleware/Vary Header Hack
More sophisticated developers attempted to use Vary headers or custom x-middleware-cache headers in middleware.ts to signal cache bypass. Unfortunately, this mostly failed. Specifically, the client router’s decision to use the cache happens before the request leaves the browser — therefore, server-side header manipulation couldn’t reach it.
The router.refresh() in useEffect Trap
// The "Desperation" Pattern — triggered infinite loops for many developers
useEffect(() => {
router.refresh();
}, []);Alternatively, developers placed router.refresh() in mount effects to force freshness. Combined with useSuspenseQuery from TanStack Query (GitHub issue #6116), this often triggered re-render cycles: refresh triggers re-fetch, re-fetch triggers state change, state change triggers re-mount, and re-mount triggers refresh again. As a result, this created a self-inflicted denial-of-service on the developer’s own API.
How Did Caching Anomalies Play Out in Real-World Production?
Analyzing real-world incident reports reveals how these silent cache behaviors led to critical data synchronization failures. Here are three typical production case studies:
Case A: Logistics Dashboard Failures (The Dispatcher’s Blindness)
- The Scenario: A real-time shipping dashboard displayed active delivery truck locations. The application used
setIntervalalongsiderouter.refresh()to pull coordinate updates from the server every 10 seconds. - The Router Cache Bug: Whenever dispatchers hovered over dashboard elements or navigated tabs, the client-side router cache intercepted the re-fetch requests. Because of the “Reset on Navigation” bug, active user interaction repeatedly reset the 30-second TTL timer, keeping stale coordinates alive indefinitely.
- The Production Impact: Dispatchers monitored outdated vehicle positions, causing shipping delays. Developers had to use the query parameter timestamp hack (
?t=${Date.now()}) to force cache busting, which resulted in significant memory leaks during long dashboard sessions.
Case B: E-Commerce Stale Cart Errors (The Ghost Cart)
- The Scenario: An e-commerce application redirected users to the
/cartpage after they successfully added a product via a Server Action. - The Router Cache Bug: Hovering over the cart icon in the header triggered a
<Link>prefetch, which cached the empty state of the cart for 5 minutes. During the Server Action redirect, the router served the cached empty cart payload instead of fetching the updated cart state from the server. - The Production Impact: The shopping cart appeared empty to the user until they performed a hard browser refresh (F5). This discrepancy caused cart abandonment rates to spike, as customers assumed the “Add to Cart” button was broken.
Case C: Next.js 404 Page Infinite Reload Loops (The Death Spiral)
- The Scenario: A user clicked a link to a deleted product page (e.g.,
/product/deleted), prompting the server to return a 404 status code. - The Router Cache Bug: The client-side router cached the 404 error page. When the user clicked a “Try Again” action that executed a
router.refresh(), the server returned a 404 again, re-triggering the error boundary. - The Production Impact: If the custom 404 page contained any automatic on-mount refresh or redirect logic, the router entered an infinite reload loop. The application repeatedly requested the error route, freezing the user’s browser tab and flooding the server with traffic.
The Version 14.2 Pivot: Admission by Configuration
In March 2024, Next.js 14.2 introduced experimental.staleTimes — the first time developers could override the hardcoded heuristics (Next.js Blog, “Next.js 14.2”, retrieved 2025-12-24):
// next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 30, // was hardcoded at 30s — now configurable
static: 180, // was hardcoded at 300s — now configurable
},
},
}Notably, the existence of this flag is itself the strongest forensic evidence that the previous values were inadequate. By exposing dynamic: 0 as a valid and frequently-recommended setting, Vercel implicitly acknowledged that for many production applications, the correct client-side cache duration is zero. In addition, the default for static also dropped from 5 minutes to 180 seconds — a 40% reduction — signaling a recalibration of the freshness vs. cache-hit-rate tradeoff.
Furthermore, the 14.2 release notes documented the “Reset Bug” fix: staleness was now calculated based on time since the user navigated away, not on an interaction-based counter. Consequently, this confirmed that the old logic was not a feature, but a bug.
Meanwhile, in Next.js 15 (released October 2024), the dynamic segment default became 0 seconds — fully uncached by default. The fetch API also changed from force-cache to no-store as the default (Next.js Blog, “Next.js 15”, retrieved 2025-12-24). Additionally, Next.js 16 introduced the use cache directive, moving toward component-level explicit caching as the governing paradigm.
What Caching Behaviors Contradicted Developer Expectations?
Surprise 1: revalidatePath was server-only all along. Developers treated it as a global invalidation signal. Forensically, it always cleared only the server-side Full Route Cache and Data Cache. It had no mechanism to reach the client’s in-memory router store. The client required a separate trigger — router.refresh() or a Server Action response — to learn about the change. The documentation implied global reach; the implementation delivered server-local reach.
Surprise 2: The client cache ignored standard HTTP semantics entirely. Standard HTTP caches respect Cache-Control: no-store. The Next.js client router cache did not — its decision to serve from memory was made before any HTTP request was issued. Developers with strong HTTP caching intuitions were systematically misled by the naming and documentation.
The Next.js router cache was not an HTTP cache and should not have been documented in the same mental model. Its failure was partly a categorization problem — a novel mechanism described using familiar terminology that implied behaviors it didn’t have.
Implications & Recommendations
Based on this investigation, here’s what developers should do depending on their current version:
Still on Next.js 14.x:
- Set
staleTimes.dynamic: 0immediately for any route displaying real-time or user-specific data. This is a one-line config change that eliminates the entire class of ghost state bugs. - Stop using
force-dynamicas a caching fix — it controls server behavior, not client cache behavior. Combine it withstaleTimes: { dynamic: 0 }if you need both. - Replace timestamp query hacks with proper
router.refresh()after Server Actions — Server Actions return signals the client router can interpret, unlike API Route Handlers.
On Next.js 15+:
- Understand the new defaults:
fetchisno-storeby default; dynamic segments are uncached by default. Audit everyfetchcall that relied on implicitforce-cachebehavior in your 14.x codebase. - Monitor for over-fetching regressions: With caching off by default, high-traffic routes may generate more backend load. Use
next.revalidateon stable data to restore selective caching.
Edge Case Q&A
staleTimes config in v14.2 (Next.js Blog, “Next.js 14.2”, March 2024). export const dynamic = 'force-dynamic' opts the page out of the server-side Full Route Cache and Data Cache — not the client-side Router Cache. The server rendered fresh HTML on every request, but the client router served the 30-second-old in-memory payload and never asked the server. Both must be combined, or developers should migrate to Next.js 15 where the dynamic segment default is already 0 seconds. Cache-Control headers from the server. The router cache ignores Cache-Control headers — its TTL is governed entirely by internal JavaScript heuristics or the staleTimes config in 14.2+. They are architecturally separate systems with no overlap. experimental.staleTimes.dynamic: 0 in next.config.js. In Next.js 15+: this is the default for dynamic segments. For specific navigation events, call router.refresh() after a Server Action mutation. Server Actions return a response the router interprets as an invalidation signal — API Route Handlers do not provide this signal automatically. Related Citations & References
- NEGuides: Caching | Next.js
- GIDeep Dive: Caching and Revalidating · vercel next.js · Discussion #54075 · GitHub
- GIISR revalidation is using stale data · Issue #58909 · vercel/next.js · GitHub
- GIrevalidatePath doesn't clear cache for client-side navigation · Issue #61184 · vercel/next.js · GitHub
- NEGuides: Prefetching | Next.js
- GIrevalidation is not triggered by “ with app router · Issue #50362 · vercel/next.js · GitHub
- GIrevalidatePath() not working as expected for Parallel Routes, or for Parallel + Intercepting Routes (modal) · Issue #54173 · vercel/next.js · GitHub
- GIuseSuspenseQuery infinite refetch after SSR (Next.js App Router) · Issue #6116 · TanStack/query · GitHub
- GIRouter.replace in useEffect creates infinite loop · vercel next.js · Discussion #46616 · GitHub
- STNext.js not-found infinite reload loop – Stack Overflow
- GInot-found.tsx: Infinite Loop when a Component Calls router.refresh · Issue #86197 · vercel/next.js · GitHub
- STreactjs – Updating state on Next.js router query change causes infinite loop – Stack Overflow
- STreactjs – How do I add a query param to Router.push in NextJS? – Stack Overflow
- GICache HITs with query parameters? · vercel next.js · Discussion #12736 · GitHub
- GINext.js and Vary — why it’s currently impossible to cache variants “properly” · vercel next.js · Discussion #82571 · GitHub
- GIApp Router Custom Header Use Cases · vercel next.js · Discussion #58110 · GitHub
- NEnext.config.js: staleTimes | Next.js
- NENext.js 14.2 | Next.js
- NENext.js 15 | Next.js




