Repository: vercel/next.js
Pull request: fix: staleTimes.static should consistently enforce a 30s minimum

When the tree response sends back a static staleTime, we had handling to ensure that it was at minimum 30s, because anything less would mean we'd be discarding static cache entries more frequently than necessary. Currently a value of 0 would result in a prefetch ping loop, because the cache value would be immediately invalidated.

This updates our config validation to warn about values lower than 30s and ensures the `getStaleTimeMs` function applies to the value read from env, too. 

Changed files:
filename: packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts
patch: @@ -15,15 +15,17 @@ import {
   navigate as navigateUsingSegmentCache,
   NavigationResultTag,
   type NavigationResult,
+  getStaleTimeMs,
 } from '../../segment-cache'
 
 // These values are set by `define-env-plugin` (based on `nextConfig.experimental.staleTimes`)
 // and default to 5 minutes (static) / 0 seconds (dynamic)
 export const DYNAMIC_STALETIME_MS =
   Number(process.env.__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME) * 1000
 
-export const STATIC_STALETIME_MS =
-  Number(process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME) * 1000
+export const STATIC_STALETIME_MS = getStaleTimeMs(
+  Number(process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME)
+)
 
 export function handleExternalUrl(
   state: ReadonlyReducerState,

filename: packages/next/src/client/components/segment-cache-impl/cache.ts
patch: @@ -93,7 +93,7 @@ import {
   DOC_PREFETCH_RANGE_HEADER_VALUE,
   doesExportedHtmlMatchBuildId,
 } from '../../../shared/lib/segment-cache/output-export-prefetch-encoding'
-import { FetchStrategy } from '../segment-cache'
+import { FetchStrategy, getStaleTimeMs } from '../segment-cache'
 import { createPromiseWithResolvers } from '../../../shared/lib/promise-with-resolvers'
 
 // A note on async/await when working in the prefetch cache:
@@ -267,14 +267,6 @@ const isOutputExportMode =
   process.env.NODE_ENV === 'production' &&
   process.env.__NEXT_CONFIG_OUTPUT === 'export'
 
-/**
- * Ensures a minimum stale time of 30s to avoid issues where the server sends a too
- * short-lived stale time, which would prevent anything from being prefetched.
- */
-function getStaleTimeMs(staleTimeSeconds: number): number {
-  return Math.max(staleTimeSeconds, 30) * 1000
-}
-
 // Route cache entries vary on multiple keys: the href and the Next-Url. Each of
 // these parts needs to be included in the internal cache key. Rather than
 // concatenate the keys into a single key, we use a multi-level map, where the

filename: packages/next/src/client/components/segment-cache.ts
patch: @@ -162,3 +162,11 @@ export type PrefetchTaskFetchStrategy =
   | FetchStrategy.PPR
   | FetchStrategy.PPRRuntime
   | FetchStrategy.Full
+
+/**
+ * Ensures a minimum stale time of 30s to avoid issues where the server sends a too
+ * short-lived stale time, which would prevent anything from being prefetched.
+ */
+export function getStaleTimeMs(staleTimeSeconds: number): number {
+  return Math.max(staleTimeSeconds, 30) * 1000
+}

filename: packages/next/src/server/config-schema.ts
patch: @@ -172,7 +172,7 @@ export const experimentalSchema = {
   staleTimes: z
     .object({
       dynamic: z.number().optional(),
-      static: z.number().optional(),
+      static: z.number().gte(30).optional(),
     })
     .optional(),
   cacheLife: z

filename: packages/next/src/server/config-shared.ts
patch: @@ -299,6 +299,7 @@ export interface ExperimentalConfig {
    */
   staleTimes?: {
     dynamic?: number
+    /** Must be greater than or equal to 30 seconds, to ensure prefetching is not completely wasteful */
     static?: number
   }
   /**