Repository: vercel/next.js
Pull request: Fix cascading LRU eviction during prefetch batches

When links remount (e.g. toggling between tabs), the prefetch scheduler processes tasks across multiple async batches due to network bandwidth limits. Between batches, LRU cleanup could fire and evict cache entries for tasks that hadn't been processed yet. Those tasks would then see cache misses, fetch fresh data, push the LRU over the limit again, and trigger more cleanup, cascading through nearly all entries.

We fix this by moving LRU cleanup into the prefetch scheduler loop. Instead of scheduling cleanup independently via `requestIdleCallback`/`setTimeout`, the LRU module pings the scheduler when the cache exceeds its size limit, and the scheduler runs cleanup only when it's fully idle (no queued tasks, no in-flight requests). At that point, all active prefetch tasks have finished reading from the cache, so eviction targets genuinely stale data.

The tradeoff is that the cache can temporarily exceed its size limit while prefetch tasks are in flight. This was already the case since cleanup was always async, but it's now more explicit since cleanup is tied to the scheduler settling rather than the next idle callback.

Changed files:
filename: packages/next/src/client/components/segment-cache/lru.ts
patch: @@ -1,11 +1,11 @@
 import { deleteMapEntry } from './cache-map'
 import type { UnknownMapEntry } from './cache-map'
+import { pingPrefetchScheduler } from './scheduler'
 
 // We use an LRU for memory management. We must update this whenever we add or
 // remove a new cache entry, or when an entry changes size.
 
 let head: UnknownMapEntry | null = null
-let didScheduleCleanup: boolean = false
 let lruSize: number = 0
 
 // TODO: I chose the max size somewhat arbitrarily. Consider setting this based
@@ -96,15 +96,20 @@ export function deleteFromLru(deleted: UnknownMapEntry) {
 }
 
 function ensureCleanupIsScheduled() {
-  if (didScheduleCleanup || lruSize <= maxLruSize) {
+  if (lruSize <= maxLruSize) {
     return
   }
-  didScheduleCleanup = true
-  requestCleanupCallback(cleanup)
+
+  // To schedule cleanup, ping the prefetch scheduler. At the end of its work
+  // loop, once there are no queued tasks and no in-progress requests, it will
+  // call cleanup().
+  pingPrefetchScheduler()
 }
 
-function cleanup() {
-  didScheduleCleanup = false
+export function cleanup() {
+  if (lruSize <= maxLruSize) {
+    return
+  }
 
   // Evict entries until we're at 90% capacity. We can assume this won't
   // infinite loop because even if `maxLruSize` were 0, eventually
@@ -120,8 +125,3 @@ function cleanup() {
     }
   }
 }
-
-const requestCleanupCallback =
-  typeof requestIdleCallback === 'function'
-    ? requestIdleCallback
-    : (cb: () => void) => setTimeout(cb, 0)

filename: packages/next/src/client/components/segment-cache/scheduler.ts
patch: @@ -44,6 +44,7 @@ import {
   PAGE_SEGMENT_KEY,
 } from '../../../shared/lib/segment'
 import type { SegmentRequestKey } from '../../../shared/lib/segment-cache/segment-value-encoding'
+import { cleanup } from './lru'
 
 const scheduleMicrotask =
   typeof queueMicrotask === 'function'
@@ -237,7 +238,7 @@ export function startRevalidationCooldown(): void {
   revalidationCooldownTimeoutHandle = setTimeout(() => {
     revalidationCooldownTimeoutHandle = null
     // Retry the prefetch queue now that the cooldown has expired.
-    ensureWorkIsScheduled()
+    pingPrefetchScheduler()
   }, REVALIDATION_COOLDOWN_MS)
 }
 
@@ -295,7 +296,7 @@ export function schedulePrefetchTask(
   // By deferring to a microtask, we only process the queue once per JS task.
   // If they have different priorities, it also ensures they are processed in
   // the optimal order.
-  ensureWorkIsScheduled()
+  pingPrefetchScheduler()
 
   return task
 }
@@ -358,7 +359,7 @@ export function reschedulePrefetchTask(
   } else {
     heapPush(taskHeap, task)
   }
-  ensureWorkIsScheduled()
+  pingPrefetchScheduler()
 }
 
 export function isPrefetchTaskDirty(
@@ -397,7 +398,7 @@ function trackMostRecentlyHoveredLink(task: PrefetchTask) {
   }
 }
 
-function ensureWorkIsScheduled() {
+export function pingPrefetchScheduler() {
   if (didScheduleMicrotask) {
     // Already scheduled a task to process the queue
     return
@@ -478,7 +479,7 @@ function onPrefetchConnectionClosed(): void {
 
   // Notify the scheduler that we have more bandwidth, and can continue
   // processing tasks.
-  ensureWorkIsScheduled()
+  pingPrefetchScheduler()
 }
 
 /**
@@ -498,7 +499,7 @@ export function pingPrefetchTask(task: PrefetchTask) {
   }
   // Add the task back to the queue.
   heapPush(taskHeap, task)
-  ensureWorkIsScheduled()
+  pingPrefetchScheduler()
 }
 
 function processQueueInMicrotask() {
@@ -563,6 +564,14 @@ function processQueueInMicrotask() {
         exitStatus satisfies never
     }
   }
+
+  // Run LRU cleanup only when the scheduler is fully idle: no queued tasks and
+  // no in-progress requests. At that point, all active prefetch tasks have
+  // finished reading from the cache (moving recently used entries to the front
+  // of the list), so only genuinely stale data gets evicted.
+  if (task === null && inProgressRequests === 0) {
+    cleanup()
+  }
 }
 
 /**

filename: test/e2e/app-dir/segment-cache/memory-pressure/segment-cache-memory-pressure.test.ts
patch: @@ -53,6 +53,15 @@ describe('segment cache memory pressure', () => {
         includes: 'Page 0.',
       }
     )
+
+    // Switching back to tab 2 should not evict and re-fetch the prefetches for
+    // Page 0 and Page 1, since they were recently accessed.
+    await act(async () => {
+      await switchToTab2.click()
+    }, [
+      { includes: 'Page 0.', block: 'reject' },
+      { includes: 'Page 1.', block: 'reject' },
+    ])
   })
 
   it('does not leak memory when repeatedly triggering prefetches', async () => {