Repository: vercel/next.js
Pull request: [segment cache]: delay revalidation prefetch pings 300ms

After performing a router revalidation (ie in a server action, or calling router.refresh()), we currently immediately re-prefetch any links on the page to ensure that subsequent navigations will be able to see any changed data. This generally works fine when self-hosting/via `next start` because the same server that is responsible for writing to the cache also handles serving it. However, when Next.js deployed and fronted by an external cache handler/CDN (eg deployed to Vercel), it can take some time to propagate. On Vercel, the documented max amount of time is 300ms. To alleviate stale data that could come about as a result of this, we will wait 300ms before triggering the prefetch again. 

Changed files:
filename: packages/next/src/client/components/segment-cache-impl/cache.ts
patch: @@ -34,6 +34,7 @@ import {
   isPrefetchTaskDirty,
   type PrefetchTask,
   type PrefetchSubtaskResult,
+  startRevalidationCooldown,
 } from './scheduler'
 import { getAppBuildId } from '../../app-build-id'
 import { createHrefFromUrl } from '../router-reducer/create-href-from-url'
@@ -327,6 +328,9 @@ export function revalidateEntireCache(
 ) {
   currentCacheVersion++
 
+  // Start a cooldown before re-prefetching to allow CDN cache propagation.
+  startRevalidationCooldown()
+
   // Clearing the cache also effectively rejects any pending requests, because
   // when the response is received, it gets written into a cache entry that is
   // no longer reachable.

filename: packages/next/src/client/components/segment-cache-impl/scheduler.ts
patch: @@ -196,6 +196,33 @@ let didScheduleMicrotask = false
 // priority at a time. We reserve special network bandwidth for this task only.
 let mostRecentlyHoveredLink: PrefetchTask | null = null
 
+// CDN cache propagation delay after revalidation (in milliseconds)
+const REVALIDATION_COOLDOWN_MS = 300
+
+// Timeout handle for the revalidation cooldown. When non-null, prefetch
+// requests are blocked to allow CDN cache propagation.
+let revalidationCooldownTimeoutHandle: ReturnType<typeof setTimeout> | null =
+  null
+
+/**
+ * Called by the cache when revalidation occurs. Starts a cooldown period
+ * during which prefetch requests are blocked to allow CDN cache propagation.
+ */
+export function startRevalidationCooldown(): void {
+  // Clear any existing timeout in case multiple revalidations happen
+  // in quick succession.
+  if (revalidationCooldownTimeoutHandle !== null) {
+    clearTimeout(revalidationCooldownTimeoutHandle)
+  }
+
+  // Schedule the cooldown to expire after the delay.
+  revalidationCooldownTimeoutHandle = setTimeout(() => {
+    revalidationCooldownTimeoutHandle = null
+    // Retry the prefetch queue now that the cooldown has expired.
+    ensureWorkIsScheduled()
+  }, REVALIDATION_COOLDOWN_MS)
+}
+
 export type IncludeDynamicData = null | 'full' | 'dynamic'
 
 /**
@@ -348,8 +375,19 @@ function ensureWorkIsScheduled() {
  * to avoid saturating the browser's internal network queue. This is a
  * cooperative limit — prefetch tasks should check this before issuing
  * new requests.
+ *
+ * Also checks if we're within the revalidation cooldown window, during which
+ * prefetch requests are delayed to allow CDN cache propagation.
  */
 function hasNetworkBandwidth(task: PrefetchTask): boolean {
+  // Check if we're within the revalidation cooldown window
+  if (revalidationCooldownTimeoutHandle !== null) {
+    // We're within the cooldown window. Return false to prevent prefetching.
+    // When the cooldown expires, the timeout will call ensureWorkIsScheduled()
+    // to retry the queue.
+    return false
+  }
+
   // TODO: Also check if there's an in-progress navigation. We should never
   // add prefetch requests to the network queue if an actual navigation is
   // taking place, to ensure there's sufficient bandwidth for render-blocking

filename: test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts
patch: @@ -314,4 +314,54 @@ describe('segment cache (revalidation)', () => {
       'no-requests'
     )
   })
+
+  it('delay re-prefetch after revalidation to allow CDN propagation', async () => {
+    let act: ReturnType<typeof createRouterAct>
+    const browser = await next.browser('/', {
+      beforePageLoad(page) {
+        act = createRouterAct(page)
+      },
+    })
+
+    const linkVisibilityToggle = await browser.elementByCss(
+      'input[data-link-accordion="/greeting"]'
+    )
+
+    // Reveal the link the target page to trigger a prefetch
+    await act(
+      async () => {
+        await linkVisibilityToggle.click()
+      },
+      {
+        includes: 'random-greeting',
+      }
+    )
+
+    // Perform an action that calls revalidatePath. This triggers a 300ms
+    // cooldown before any new prefetch requests can be made.
+    const revalidateByPath = await browser.elementById('revalidate-by-path')
+    await revalidateByPath.click()
+
+    // Immediately after revalidation, no prefetch should have occurred yet
+    await new Promise((resolve) => setTimeout(resolve, 50))
+    TestLog.assert([])
+
+    // Halfway through cooldown (150ms), still no prefetch
+    await new Promise((resolve) => setTimeout(resolve, 100))
+    TestLog.assert([])
+
+    // After cooldown expires (300ms + buffer), prefetch should have occurred
+    await new Promise((resolve) => setTimeout(resolve, 200))
+    TestLog.assert(['REQUEST: random-greeting'])
+
+    // Navigate to the target page.
+    await act(async () => {
+      const link = await browser.elementByCss('a[href="/greeting"]')
+      await link.click()
+      // Navigation should finish immediately because the page is
+      // fully prefetched.
+      const greeting = await browser.elementById('greeting')
+      expect(await greeting.innerHTML()).toBe('random-greeting [1]')
+    }, 'no-requests')
+  })
 })

filename: test/lib/router-act.ts
patch: @@ -309,7 +309,32 @@ export function createRouterAct(
       const remaining = new Set<PendingRSCRequest>()
       let actualResponses: Array<ExpectedResponseConfig> = []
       let alreadyMatched = new Map<string, string>()
-      while (batch.pendingRequests.size > 0) {
+
+      // Track when the queue was last empty to implement a settling period
+      let queueEmptyStartTime: number | null = null
+      const SETTLING_PERIOD_MS = 500 // Wait 500ms after queue empties
+
+      while (
+        batch.pendingRequests.size > 0 ||
+        queueEmptyStartTime === null ||
+        Date.now() - queueEmptyStartTime < SETTLING_PERIOD_MS
+      ) {
+        if (batch.pendingRequests.size > 0) {
+          // Queue has requests, reset settling timer
+          queueEmptyStartTime = null
+        } else if (queueEmptyStartTime === null) {
+          // Queue just became empty, start settling timer
+          queueEmptyStartTime = Date.now()
+        }
+
+        if (batch.pendingRequests.size === 0) {
+          // Queue is empty during settling period, wait a bit and check again
+          await new Promise((resolve) => setTimeout(resolve, 50))
+          await waitForIdleCallback()
+          await waitForPendingRequestChecks()
+          continue
+        }
+
         const pending = batch.pendingRequests
         batch.pendingRequests = new Set()
         for (const item of pending) {