Skip to content

Honor the route-level expire value with blocking revalidation#93211

Draft
unstubbable wants to merge 1 commit intocanaryfrom
hl/fix-expire-handling
Draft

Honor the route-level expire value with blocking revalidation#93211
unstubbable wants to merge 1 commit intocanaryfrom
hl/fix-expire-handling

Conversation

@unstubbable
Copy link
Copy Markdown
Contributor

A prerendered route's expire — set via cacheLife({ expire }) inside 'use cache' or via the expireTime config fallback — lands in the prerender manifest as initialExpireSeconds / fallbackExpire (#76207), but the runtime never read it: IncrementalCache.get only considered revalidate. So past expire, Next.js served stale with a background refresh instead of the blocking regeneration the cacheLife expire docs describe.

The fix is two coordinated changes. IncrementalCache.get now returns isStale = -1 when lastModified + expire * 1000 < now, and response-cache.handleGet skips its early resolve(previousEntry) for isStale === -1 so the blocking revalidation inside responseGenerator (which already picks BLOCKING_STATIC_RENDER on that signal) can return its fresh output to the user. Previously the early resolve committed the stale value to the response first, so even though responseGenerator still ran a fresh render its output only warmed the cache for the next request. As a side effect this also closes the same early-resolve hole on the existing tag-expired isStale = -1 path.

On Vercel, ISR cache decisions live at the Proxy and the Proxy currently ignores staleExpiration (using a hard-coded one-year value instead). It is also expected, once it starts honoring staleExpiration, to pick up updated values from the stale-while-revalidate response header. Until that lands this change is only observable on next start — deploy-mode behavior is tracked independently of Next.js.

Two test suites cover the new behavior. test/production/app-dir/use-cache-expire uses cacheComponents + cacheLife({ expire: 300 }) with a custom cache handler that shifts lastModified via an x-test-cache-age-offset-ms header, exercising the fully-static shell, the partially-static route shell for a known param, and the partially-static fallback shell for unknown params. test/e2e/app-dir/expire-time covers classic ISR (revalidate = 1, expireTime: 2) with a real three-second wait and is it.failing on deploy, so it will flip the moment the Proxy honors the expire value.

fixes #78269

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

Failing test suites

Commit: 924f3b9 | About building and testing Next.js

pnpm test-deploy test/e2e/app-dir/expire-time/expire-time.test.ts (job)

  • expire-time > should do a blocking revalidation when the cache entry has expired (DD)
Expand output

● expire-time › should do a blocking revalidation when the cache entry has expired

Failing test passed even though it was supposed to fail. Remove `.failing` to remove error.

  50 |       }
  51 |
> 52 |       const result = Reflect.apply(target, thisArg, args)
     |                              ^
  53 |       return typeof result === 'function' ? wrapJestTestFn(result) : result
  54 |     },
  55 |     get(target, prop, receiver) {

  at Object.apply (lib/e2e-utils/index.ts:52:30)
  at itFailsWhenDeployed (e2e/app-dir/expire-time/expire-time.test.ts:23:3)
  at Object.describe (e2e/app-dir/expire-time/expire-time.test.ts:3:1)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

Stats from current PR

🔴 1 regression, 1 improvement

Metric Canary PR Change
node_modules Size 495 MB 495 MB 🔴 +75.5 kB (+0%)
Webpack Build Time 23.884s 23.289s 🟢 595ms (-2%)
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change
Cold (Listen) 811ms 811ms
Cold (Ready in log) 775ms 774ms
Cold (First Request) 1.191s 1.199s
Warm (Listen) 812ms 812ms
Warm (Ready in log) 774ms 775ms
Warm (First Request) 571ms 571ms
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change
Cold (Listen) 811ms 810ms
Cold (Ready in log) 779ms 778ms
Cold (First Request) 3.107s 3.103s
Warm (Listen) 810ms 810ms
Warm (Ready in log) 780ms 780ms
Warm (First Request) 3.134s 3.148s

⚡ Production Builds

Metric Canary PR Change
Fresh Build 4.809s 4.864s
Cached Build 4.823s 4.849s
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change
Fresh Build 23.884s 23.289s 🟢 595ms (-2%)
Cached Build 23.619s 23.387s
node_modules Size 495 MB 495 MB 🔴 +75.5 kB (+0%)
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
07rxhp_1_g4mu.js gzip 13.1 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0fli3_wppnim5.js gzip 12.9 kB N/A -
0jaojkrb2rf_l.js gzip 158 B N/A -
0k09jwjeb-tki.js gzip 13.8 kB N/A -
0kb7_ep3r1z0_.js gzip 10.1 kB N/A -
0kw8xgqdrilf6.js gzip 8.56 kB N/A -
0ojkk2e654xsc.js gzip 8.59 kB N/A -
0wxpyd8r-vipl.js gzip 1.47 kB N/A -
0xy2fhla48_rd.js gzip 9.24 kB N/A -
0zg1_op6gpm77.js gzip 159 B N/A -
10u1wg6830n69.js gzip 155 B N/A -
10wqsvi2mgfmi.js gzip 9.82 kB N/A -
16lhqjoqbznyg.js gzip 220 B 220 B
16vepdkipri3r.js gzip 8.51 kB N/A -
17n96uu6y1pxq.js gzip 8.6 kB N/A -
18y4_8-9or0mn.js gzip 8.51 kB N/A -
1ejk4yy877m2s.js gzip 156 B N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1gq145j3kps-h.js gzip 8.62 kB N/A -
1nsh-mbn0e-se.js gzip 8.56 kB N/A -
1tsrrp1tdngti.js gzip 13.3 kB N/A -
1wbblhm8kdg1d.js gzip 70.9 kB N/A -
2__-e_ym8n788.js gzip 450 B N/A -
2-1kdwlsg8h5k.js gzip 49.5 kB N/A -
21h2q5l_ijxo1.js gzip 156 B N/A -
22o6xd9_ywdu6.js gzip 233 B N/A -
25ee1c0k3yry7.js gzip 155 B N/A -
25n272-g99oa1.js gzip 7.61 kB N/A -
2jqyc54izxg_5.js gzip 154 B N/A -
2kvj8yrfznmwx.js gzip 5.69 kB N/A -
2m-x8jeelz1qx.js gzip 169 B N/A -
2qv7m7xjnokgr.js gzip 8.58 kB N/A -
2t9oc38of5_ar.js gzip 153 B N/A -
2zf1ecbz94sp-.js gzip 65.5 kB N/A -
342ijzvrpe53h.js gzip 2.29 kB N/A -
3b0qzjz24jcjn.js gzip 157 B N/A -
3k1k5gtofm6eq.js gzip 10.4 kB N/A -
3m606uqf8ws_m.js gzip 155 B N/A -
3msaeh9eztxfm.js gzip 161 B N/A -
3pgbrgq66slx4.js gzip 155 B N/A -
turbopack-02..a6-3.js gzip 4.19 kB N/A -
turbopack-19..b8v3.js gzip 4.19 kB N/A -
turbopack-1k..fy7t.js gzip 4.19 kB N/A -
turbopack-1n..nqqb.js gzip 4.2 kB N/A -
turbopack-1o..qxp_.js gzip 4.17 kB N/A -
turbopack-1z..cxi_.js gzip 4.19 kB N/A -
turbopack-21..uim9.js gzip 4.19 kB N/A -
turbopack-27..jjpo.js gzip 4.19 kB N/A -
turbopack-2m..i5vn.js gzip 4.19 kB N/A -
turbopack-2m..1fe1.js gzip 4.19 kB N/A -
turbopack-2v..1576.js gzip 4.19 kB N/A -
turbopack-33..fmta.js gzip 4.19 kB N/A -
turbopack-3q..h-7b.js gzip 4.19 kB N/A -
turbopack-3u..fo-_.js gzip 4.19 kB N/A -
01ofzfnqdbuen.js gzip N/A 158 B -
0arkbdqpxc37i.js gzip N/A 8.6 kB -
0bz-xifewa17d.js gzip N/A 8.63 kB -
0gh_yv8qw4m5-.js gzip N/A 157 B -
0im0h0br03kar.js gzip N/A 156 B -
0tvekitj587fh.js gzip N/A 8.51 kB -
0yvk6-wi8e9wh.js gzip N/A 13.3 kB -
1-jqyfc89tixo.js gzip N/A 1.46 kB -
10y3h86mnhs_2.js gzip N/A 10.4 kB -
14t1kneseb8th.js gzip N/A 2.3 kB -
15sb1-dsqfk_j.js gzip N/A 8.59 kB -
1ab2xruymo-oj.js gzip N/A 449 B -
1cz0kmzuvr895.js gzip N/A 70.9 kB -
1tu25qtsmfhar.js gzip N/A 9.82 kB -
1vein_gnv3mwr.js gzip N/A 8.56 kB -
1wzrm0xjjbzn5.js gzip N/A 10.1 kB -
1z3g0uaqtv9_3.js gzip N/A 8.56 kB -
25a1yz7zua29z.js gzip N/A 13.8 kB -
2auzvn7t9xf96.js gzip N/A 65.5 kB -
2bi5hx402juv-.js gzip N/A 8.58 kB -
2fuln2dxq8d_0.js gzip N/A 154 B -
2hy56297fog9u.js gzip N/A 8.52 kB -
2i-0fkdny69iz.js gzip N/A 49.5 kB -
2qd9d0wg7yxwc.js gzip N/A 158 B -
2u_rpxq3tzytl.js gzip N/A 233 B -
2wr55o64ssudv.js gzip N/A 161 B -
2yy2v4vukl6e0.js gzip N/A 161 B -
35-eg4zotgxro.js gzip N/A 157 B -
35nh2lh_i5pyh.js gzip N/A 7.61 kB -
368lim5wq0o0r.js gzip N/A 12.9 kB -
3drqjohogojbw.js gzip N/A 5.69 kB -
3g8l1m2-o-ewi.js gzip N/A 13.1 kB -
3ixmpqnyxmqfs.js gzip N/A 170 B -
3k0wlheipb1ej.js gzip N/A 159 B -
3km28rtkbqo-g.js gzip N/A 158 B -
3n9xy43ds2l11.js gzip N/A 159 B -
3tfgis6xa1unl.js gzip N/A 158 B -
3wpp8nvyoj121.js gzip N/A 9.24 kB -
turbopack-0-..xakf.js gzip N/A 4.19 kB -
turbopack-09..ikej.js gzip N/A 4.19 kB -
turbopack-0j..nybk.js gzip N/A 4.19 kB -
turbopack-0o..1pwm.js gzip N/A 4.21 kB -
turbopack-1r..xsvl.js gzip N/A 4.19 kB -
turbopack-1t..xctc.js gzip N/A 4.19 kB -
turbopack-28..ffdj.js gzip N/A 4.19 kB -
turbopack-2k..ao0y.js gzip N/A 4.19 kB -
turbopack-2o..i1c7.js gzip N/A 4.19 kB -
turbopack-2o..wsn2.js gzip N/A 4.19 kB -
turbopack-2t..clq6.js gzip N/A 4.19 kB -
turbopack-2w..ebur.js gzip N/A 4.17 kB -
turbopack-35..-wtq.js gzip N/A 4.19 kB -
turbopack-3w..fwa0.js gzip N/A 4.19 kB -
Total 465 kB 465 kB ⚠️ +83 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 718 B 721 B
Total 718 B 721 B ⚠️ +3 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 435 B 433 B
Total 435 B 433 B ✅ -2 B

📦 Webpack

Client

Main Bundles
Canary PR Change
2637-HASH.js gzip 4.63 kB N/A -
7724.HASH.js gzip 169 B N/A -
8274-HASH.js gzip 61.4 kB N/A -
8817-HASH.js gzip 5.59 kB N/A -
c3500254-HASH.js gzip 62.8 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 254 B 254 B
main-HASH.js gzip 39.4 kB 39.3 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
5887-HASH.js gzip N/A 5.61 kB -
6522-HASH.js gzip N/A 60.7 kB -
6779-HASH.js gzip N/A 4.63 kB -
8854.HASH.js gzip N/A 169 B -
eab920f9-HASH.js gzip N/A 62.8 kB -
Total 236 kB 235 kB ✅ -633 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 193 B 193 B
_error-HASH.js gzip 182 B 182 B
css-HASH.js gzip 333 B 334 B
dynamic-HASH.js gzip 1.81 kB 1.8 kB
edge-ssr-HASH.js gzip 255 B 255 B
head-HASH.js gzip 353 B 349 B 🟢 4 B (-1%)
hooks-HASH.js gzip 384 B 382 B
image-HASH.js gzip 581 B 581 B
index-HASH.js gzip 260 B 259 B
link-HASH.js gzip 2.52 kB 2.52 kB
routerDirect..HASH.js gzip 316 B 318 B
script-HASH.js gzip 386 B 386 B
withRouter-HASH.js gzip 313 B 314 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.99 kB 7.98 kB ✅ -10 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 126 kB 126 kB
page.js gzip 274 kB 274 kB
Total 400 kB 399 kB ✅ -566 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 618 B 617 B
middleware-r..fest.js gzip 156 B 156 B
middleware.js gzip 44.4 kB 44.5 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 46 kB 46.1 kB ⚠️ +92 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 722 B 719 B
Total 722 B 719 B ✅ -3 B
Build Cache
Canary PR Change
0.pack gzip 4.4 MB 4.4 MB
index.pack gzip 115 kB 113 kB 🟢 1.73 kB (-2%)
index.pack.old gzip 114 kB 113 kB
Total 4.63 MB 4.62 MB ✅ -5.93 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 348 kB 348 kB
app-page-exp..prod.js gzip 193 kB 193 kB
app-page-tur...dev.js gzip 348 kB 348 kB
app-page-tur..prod.js gzip 193 kB 193 kB
app-page-tur...dev.js gzip 344 kB 344 kB
app-page-tur..prod.js gzip 191 kB 191 kB
app-page.run...dev.js gzip 345 kB 345 kB
app-page.run..prod.js gzip 191 kB 191 kB
app-route-ex...dev.js gzip 77.3 kB 77.3 kB
app-route-ex..prod.js gzip 52.7 kB 52.8 kB
app-route-tu...dev.js gzip 77.3 kB 77.4 kB
app-route-tu..prod.js gzip 52.8 kB 52.8 kB
app-route-tu...dev.js gzip 76.9 kB 77 kB
app-route-tu..prod.js gzip 52.5 kB 52.6 kB
app-route.ru...dev.js gzip 76.9 kB 76.9 kB
app-route.ru..prod.js gzip 52.5 kB 52.5 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 44.2 kB 44.2 kB
pages-api-tu..prod.js gzip 33.6 kB 33.7 kB
pages-api.ru...dev.js gzip 44.2 kB 44.2 kB
pages-api.ru..prod.js gzip 33.6 kB 33.7 kB
pages-turbo....dev.js gzip 53.5 kB 53.6 kB
pages-turbo...prod.js gzip 39.3 kB 39.3 kB
pages.runtim...dev.js gzip 53.5 kB 53.6 kB
pages.runtim..prod.js gzip 39.3 kB 39.3 kB
server.runti..prod.js gzip 63.1 kB 63.1 kB
Total 3.08 MB 3.08 MB ⚠️ +1.08 kB
📝 Changed Files (25 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route.runtime.dev.js
  • app-route.ru..time.prod.js
  • pages-api-tu..ntime.dev.js
  • pages-api-tu..time.prod.js
  • pages-api.runtime.dev.js
  • pages-api.ru..time.prod.js
  • ... and 5 more
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js

Diff too large to display

app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route.runtime.dev.js

Diff too large to display

app-route.ru..time.prod.js

Diff too large to display

pages-api-tu..ntime.dev.js

Diff too large to display

pages-api-tu..time.prod.js

Diff too large to display

pages-api.runtime.dev.js

Diff too large to display

pages-api.ru..time.prod.js

Diff too large to display

pages-turbo...ntime.dev.js

Diff too large to display

pages-turbo...time.prod.js

Diff too large to display

pages.runtime.dev.js

Diff too large to display

pages.runtime.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/924f3b9a9530e9dabed18d8e4dc42fce6f99730e/next

Commit: 924f3b9

A prerendered route's `expire` — set via `cacheLife({ expire })` inside
`'use cache'` or via the `expireTime` config fallback — lands in the
prerender manifest as `initialExpireSeconds` / `fallbackExpire`
(#76207), but the runtime never read it: `IncrementalCache.get` only
considered `revalidate`. So past expire, Next.js served stale with a
background refresh instead of the blocking regeneration the
`cacheLife` `expire` docs describe.

The fix is two coordinated changes. `IncrementalCache.get` now returns
`isStale = -1` when `lastModified + expire * 1000 < now`, and
`response-cache.handleGet` skips its early `resolve(previousEntry)` for
`isStale === -1` so the blocking revalidation inside `responseGenerator`
(which already picks `BLOCKING_STATIC_RENDER` on that signal) can return
its fresh output to the user. Previously the early resolve committed the
stale value to the response first, so even though `responseGenerator`
still ran a fresh render its output only warmed the cache for the next
request. As a side effect this also closes the same early-resolve hole
on the existing tag-expired `isStale = -1` path.

On Vercel, ISR cache decisions live at the Proxy and the Proxy currently
ignores `staleExpiration` (using a hard-coded one-year value instead).
It is also expected, once it starts honoring `staleExpiration`, to pick
up updated values from the `stale-while-revalidate` response header.
Until that lands this change is only observable on `next start` —
deploy-mode behavior is tracked independently of Next.js.

Two test suites cover the new behavior.
`test/production/app-dir/use-cache-expire` uses `cacheComponents` +
`cacheLife({ expire: 300 })` with a custom cache handler that shifts
`lastModified` via an `x-test-cache-age-offset-ms` header, exercising
the fully-static shell, the partially-static route shell for a known
param, and the partially-static fallback shell for unknown params.
`test/e2e/app-dir/expire-time` covers classic ISR (`revalidate = 1`,
`expireTime: 2`) with a real three-second wait and is `it.failing` on
deploy, so it will flip the moment the Proxy honors the expire value.

fixes #78269
@unstubbable unstubbable force-pushed the hl/fix-expire-handling branch from 83b9721 to 924f3b9 Compare April 25, 2026 12:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unexpected cacheLife expire behaviour

1 participant