Files
Hendrik LiebauandGitHub 521ae9653e Fix route handler SWR blocking on Node runtime (#93189)
In a Node-runtime app route handler, `'use cache'` and fetch-cache
stale-while-revalidate block the client response on the full background
regeneration. A second request after the cache has gone stale does not
return the stale value promptly. It waits the entire regen duration and
then returns the freshly regenerated value, as described in issue
#93146. The symptom is visible both on self-hosted Node and on Vercel
Node. Edge route handlers were unaffected because they route through
`edge-route-module-wrapper.ts`, which hands `pendingWaitUntil` directly
to `evt.waitUntil` and never awaits it before the response completes.

The root cause is independent of the `await ignoredStream.cancel()`
change proposed in that issue, and also independent of #92636, which
removed a separate blocking await inside `use-cache-wrapper.ts`. Both of
those sit inside the `'use cache'` wrapper and affect how the stale
entry is returned to the caller. The blocking that remains is in the
transport layer and applies equally to any revalidation pushed into
`pendingRevalidates` or `pendingRevalidateWrites`, not just `'use
cache'`.

#74164 migrated pending revalidate handling from the "keep the response
stream open until the promise resolves" pattern introduced in #55978 and
reinforced in #58744 to a conditional hand-off. If a platform
`waitUntil` is available, revalidations run out of band via
`ctx.waitUntil`. Otherwise `pipe-readable.ts` keeps `res.end` deferred
until they settle, so minimal-mode deployments stay alive long enough
for writes to persist.

That migration landed correctly for app pages. `app-render.tsx` only
assigns `options.waitUntil` in the `else` branch, so once
`renderOpts.waitUntil` is present the `pipe-readable.ts` path receives
nothing and does not block. The matching code for route handlers was
added to `base-server.ts` in the same PR. It declared `let
pendingWaitUntil = context.renderOpts.pendingWaitUntil`, cleared it when
handing off to `ctx.waitUntil`, and then passed
`context.renderOpts.pendingWaitUntil` into `sendResponse`. That is the
unmutated property, not the cleared local. The local variable was never
read. The hand-off therefore never displaced the pipe-readable await.
The same promise was awaited twice. `ctx.waitUntil` registered it
redundantly and `pipe-readable.ts` still held `res.end` open for the
full revalidation.

#80189 copied the block verbatim into
`packages/next/src/build/templates/app-route.ts`, carrying the bug
forward when response handling moved into the route template. Fixing it
is a one-line change. Pass the local `pendingWaitUntil` (which is
`undefined` once handed off) into `sendResponse`, so Node route handlers
match both app pages and edge route handlers and the original intent of
#74164 is restored.

The existing `stale-cache-serving/route-handler` test in
`app-static.test.ts` did not catch this. It only measured
time-to-first-byte against the route start time, and first-byte was
already fast pre-fix. Chunks are written before `pipe-readable.ts`'s
close handler awaits `waitUntilForEnd`. Only the terminating chunk was
delayed. The test is updated to also assert total response time against
the route start. Pre-fix that new assertion fails at ~3000ms for the
Node route handler variant while the page and edge variants continue to
pass, which is exactly the scope of the regression. A new e2e test in
`use-cache-swr` exercises the symmetric `'use cache'` path through a
route handler fixture.

fixes #93146
closes #93177 (incorrect fix)
closes #93188 (incorrect fix)
2026-04-28 18:40:46 +02:00
..
…