Repository: vercel/next.js
Pull request: [Segment Cache] Always upsert on prefetch completion

**Previous:**

1. https://github.com/vercel/next.js/pull/91487

**Current:**

2. https://github.com/vercel/next.js/pull/91488

**Up next:**

3. https://github.com/vercel/next.js/pull/89297

---

When a prefetch response includes vary params, the segment cache rekeys entries to a more generic path based on which params the segment actually depends on. Previously, the rekeying only happened when vary params were provided. Now that vary params are tracked for more response types (and eventually will always be tracked), entries are rekeyed in more cases than before.

This exposed a potential race condition: the scheduler would capture a vary path at scheduling time and upsert the entry at that path when the fetch completed. But the fetch functions themselves rekey entries to a different (more generic) path upon fulfillment. The deferred upsert could then move the entry back to the less generic path, undoing the rekeying.

To fix this, move the upsert logic inline into the fetch functions that fulfill entries, rather than deferring it to an external callback. This removes the race condition, simplifies the model, and reduces implementation complexity. The previous structure existed to avoid the rekeying cost when vary params weren't available, but rekeying is inexpensive and not worth the added indirection.

The upsert function itself already handles concurrent writes by comparing fetch strategies and checking whether the new entry provides more complete data than any existing entry. So it's safe to always call it inline — whichever entry wins will be the most complete one.

Changed files:
filename: packages/next/src/client/components/segment-cache/cache.ts
patch: @@ -1938,7 +1938,8 @@ export async function fetchSegmentOnCacheMiss(
       rejectSegmentCacheEntry(segmentCacheEntry, Date.now() + 10 * 1000)
       return null
     }
-    const staleAt = Date.now() + getStaleTimeMs(serverData.staleTime)
+    const now = Date.now()
+    const staleAt = now + getStaleTimeMs(serverData.staleTime)
     const fulfilledEntry = fulfillSegmentCacheEntry(
       segmentCacheEntry,
       serverData.rsc,
@@ -1951,21 +1952,14 @@ export async function fetchSegmentOnCacheMiss(
     // across different param values for params that the segment doesn't
     // actually depend on.
     const varyParams = serverData.varyParams
-    if (process.env.__NEXT_VARY_PARAMS && varyParams !== null) {
-      // Re-key the entry by storing it at a more generic vary path where
-      // unused params are replaced with Fallback.
-      const fulfilledVaryPath = getFulfilledSegmentVaryPath(
-        tree.varyPath,
-        varyParams
-      )
-      const isRevalidation = false
-      setInCacheMap(
-        segmentCacheMap,
-        fulfilledVaryPath,
-        fulfilledEntry,
-        isRevalidation
-      )
-    }
+    const fulfilledVaryPath =
+      process.env.__NEXT_VARY_PARAMS && varyParams !== null
+        ? getFulfilledSegmentVaryPath(tree.varyPath, varyParams)
+        : getSegmentVaryPathForRequest(segmentCacheEntry.fetchStrategy, tree)
+    // Re-key and upsert the entry at the fulfilled vary path. This ensures
+    // the entry is stored at the most generic path possible based on which
+    // params the segment actually depends on.
+    upsertSegmentEntry(now, fulfilledVaryPath, fulfilledEntry)
 
     return {
       value: fulfilledEntry,

filename: packages/next/src/client/components/segment-cache/scheduler.ts
patch: @@ -20,16 +20,12 @@ import {
   type PendingSegmentCacheEntry,
   convertRouteTreeToFlightRouterState,
   readOrCreateRevalidatingSegmentEntry,
-  upsertSegmentEntry,
-  type FulfilledSegmentCacheEntry,
   upgradeToPendingSegment,
-  waitForSegmentCacheEntry,
   overwriteRevalidatingSegmentCacheEntry,
   canNewFetchStrategyProvideMoreContent,
   attemptToFulfillDynamicSegmentFromBFCache,
   attemptToUpgradeSegmentFromBFCache,
 } from './cache'
-import { getSegmentVaryPathForRequest, type SegmentVaryPath } from './vary-path'
 import type { RouteCacheKey } from './cache-key'
 import { createCacheKey } from './cache-key'
 import {
@@ -1636,18 +1632,15 @@ function pingPPRSegmentRevalidation(
   )
   switch (revalidatingSegment.status) {
     case EntryStatus.Empty:
-      // Spawn a prefetch request and upsert the segment into the cache
-      // upon completion.
-      upsertSegmentOnCompletion(
-        spawnPrefetchSubtask(
-          fetchSegmentOnCacheMiss(
-            route,
-            upgradeToPendingSegment(revalidatingSegment, FetchStrategy.PPR),
-            routeKey,
-            tree
-          )
-        ),
-        getSegmentVaryPathForRequest(FetchStrategy.PPR, tree)
+      // Spawn a prefetch request. The fetch function handles upserting
+      // the entry at the correct fulfilled vary path upon completion.
+      spawnPrefetchSubtask(
+        fetchSegmentOnCacheMiss(
+          route,
+          upgradeToPendingSegment(revalidatingSegment, FetchStrategy.PPR),
+          routeKey,
+          tree
+        )
       )
       break
     case EntryStatus.Pending:
@@ -1684,10 +1677,8 @@ function pingFullSegmentRevalidation(
       revalidatingSegment,
       fetchStrategy
     )
-    upsertSegmentOnCompletion(
-      waitForSegmentCacheEntry(pendingSegment),
-      getSegmentVaryPathForRequest(fetchStrategy, tree)
-    )
+    // The upsert is handled by fulfillEntrySpawnedByRuntimePrefetch
+    // when the dynamic prefetch response is written into the cache.
     return pendingSegment
   } else {
     // There's already a revalidation in progress.
@@ -1709,10 +1700,8 @@ function pingFullSegmentRevalidation(
         emptySegment,
         fetchStrategy
       )
-      upsertSegmentOnCompletion(
-        waitForSegmentCacheEntry(pendingSegment),
-        getSegmentVaryPathForRequest(fetchStrategy, tree)
-      )
+      // The upsert is handled by fulfillEntrySpawnedByRuntimePrefetch
+      // when the dynamic prefetch response is written into the cache.
       return pendingSegment
     }
     switch (nonEmptyRevalidatingSegment.status) {
@@ -1732,21 +1721,6 @@ function pingFullSegmentRevalidation(
   }
 }
 
-const noop = () => {}
-
-function upsertSegmentOnCompletion(
-  promise: Promise<FulfilledSegmentCacheEntry | null>,
-  varyPath: SegmentVaryPath
-) {
-  // Wait for a segment to finish loading, then upsert it into the cache
-  promise.then((fulfilled) => {
-    if (fulfilled !== null) {
-      // Received new data. Attempt to replace the existing entry in the cache.
-      upsertSegmentEntry(Date.now(), varyPath, fulfilled)
-    }
-  }, noop)
-}
-
 function doesCurrentSegmentMatchCachedSegment(
   route: FulfilledRouteCacheEntry,
   currentSegment: Segment,