Summary
On the Node runtime, "use cache" stale-while-revalidate never serves stale — it always blocks the user response on the full background regeneration. Root cause: await ignoredStream.cancel() in use-cache-wrapper.ts, combined with Node's Web Streams tee semantics where cancelling one branch waits until the source stream closes.
Offending line (canary): packages/next/src/server/use-cache/use-cache-wrapper.ts#L2668
To Reproduce
This is a platform-level behavior of Node's Web Streams, not something that needs a full Next.js reproduction to verify. Pure-Node repro (under 30 lines): https://gist.github.com/haydenshively/e5b7fb69088bac8e4d477ec967c7c891
$ node tee-cancel-repro.js
b read at 2003 ms, done=false
a.cancel() resolved at 6004 ms <-- waits for source CLOSE, not cancel
In the cache wrapper's SWR branch, ignoredStream is one half of a tee()d stream whose source is renderToReadableStream(resultPromise, ...). The other branch (savedStream) is actively being read by collectResult to populate pendingCacheEntry. Because of that active reader, the source stream does not close until the regen fn has fully resolved and all RSC chunks have been emitted. And because of Node's tee-cancel semantics, await ignoredStream.cancel() therefore waits that full duration before resuming the wrapper.
That suspends the wrapper body, which delays the return createFromReadableStream(stream /* stale */) at the bottom of cache(), which is exactly what the user's request is awaiting. Net effect: SWR becomes "block until fresh."
To observe in a real app:
- App Router route handler that returns JSON via an async
"use cache" function whose body takes multiple seconds (e.g. several RPC calls).
cacheLife({ revalidate: 60, expire: 86400 }) on the cached function. Use the default Node runtime.
- Populate the cache by hitting the endpoint once. Wait > 60s. Hit again.
Expected: the second hit returns the stale value in < 100ms and triggers a background regen.
Observed: the second hit blocks for the full regen duration and then returns the freshly-computed value.
Current vs. Expected behavior
Current: await ignoredStream.cancel() at the end of the SWR branch synchronously waits for the regen source to close, which is equivalent to waiting for the regen fn to fully resolve. The user's response is therefore blocked on the regen for its entire duration.
Expected: In the SWR branch the user should receive the stale stream immediately and the regen should complete out-of-band (that's what the "use cache" contract and its documentation imply, and what the Edge runtime wrapper at edge-route-module-wrapper.ts does by handing pendingWaitUntil directly to evt.waitUntil without awaiting it).
Proposed fix (locally verified against 16.1.7):
- await ignoredStream.cancel()
+ void ignoredStream.cancel().catch(() => {})
Nothing downstream reads from ignoredStream, so there is no observer waiting on the cancel's resolution — the await on it serves no purpose and only couples the caller to the tee-cancel's blocking semantic.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.6.0
Binaries:
Node: 24.14.1
npm: 11.11.0
pnpm: 10.32.1
Relevant Packages:
next: 16.1.7 (also reproduces on canary — offending line unchanged)
react: 19.2.4
react-dom: 19.2.4
typescript: 5.9.3
Next.js Config:
output: standalone
Canary verified by inspection only (offending line still present at canary use-cache-wrapper.ts:2668). Happy to test against a canary build if a maintainer prefers.
Which area(s) are affected? (Select all that apply)
Use Cache, Route Handlers, Runtime
Which stage(s) are affected? (Select all that apply)
next dev (local), next start (local), Vercel (Deployed), Other (Deployed)
Additional context
Affects both next dev and deployed Node-runtime route handlers. The Edge runtime path is unaffected because edge-route-module-wrapper.ts hands pendingWaitUntil directly to evt.waitUntil(...) instead of awaiting it before res.end() (via pipe-readable.ts).
Also worth noting that because pendingRevalidateWrites is awaited in pipe-readable.ts's writer close handler before res.end() on the Node runtime, custom CacheHandler.set implementations that correctly return an already-resolved promise (so the lambda/function isn't gated on the persistence write) still see the response blocked — because the block is inside the wrapper itself, before cacheHandler.set's return value is even relevant. This one-line change is the only thing that unblocks Node-runtime SWR.
Summary
On the Node runtime,
"use cache"stale-while-revalidate never serves stale — it always blocks the user response on the full background regeneration. Root cause:await ignoredStream.cancel()inuse-cache-wrapper.ts, combined with Node's Web Streams tee semantics where cancelling one branch waits until the source stream closes.Offending line (canary):
packages/next/src/server/use-cache/use-cache-wrapper.ts#L2668To Reproduce
This is a platform-level behavior of Node's Web Streams, not something that needs a full Next.js reproduction to verify. Pure-Node repro (under 30 lines): https://gist.github.com/haydenshively/e5b7fb69088bac8e4d477ec967c7c891
In the cache wrapper's SWR branch,
ignoredStreamis one half of atee()d stream whose source isrenderToReadableStream(resultPromise, ...). The other branch (savedStream) is actively being read bycollectResultto populatependingCacheEntry. Because of that active reader, the source stream does not close until the regenfnhas fully resolved and all RSC chunks have been emitted. And because of Node's tee-cancel semantics,await ignoredStream.cancel()therefore waits that full duration before resuming the wrapper.That suspends the wrapper body, which delays the
return createFromReadableStream(stream /* stale */)at the bottom ofcache(), which is exactly what the user's request isawaiting. Net effect: SWR becomes "block until fresh."To observe in a real app:
"use cache"function whose body takes multiple seconds (e.g. several RPC calls).cacheLife({ revalidate: 60, expire: 86400 })on the cached function. Use the default Node runtime.Expected: the second hit returns the stale value in < 100ms and triggers a background regen.
Observed: the second hit blocks for the full regen duration and then returns the freshly-computed value.
Current vs. Expected behavior
Current:
await ignoredStream.cancel()at the end of the SWR branch synchronously waits for the regen source to close, which is equivalent to waiting for the regenfnto fully resolve. The user's response is therefore blocked on the regen for its entire duration.Expected: In the SWR branch the user should receive the stale stream immediately and the regen should complete out-of-band (that's what the
"use cache"contract and its documentation imply, and what the Edge runtime wrapper atedge-route-module-wrapper.tsdoes by handingpendingWaitUntildirectly toevt.waitUntilwithout awaiting it).Proposed fix (locally verified against 16.1.7):
Nothing downstream reads from
ignoredStream, so there is no observer waiting on the cancel's resolution — theawaiton it serves no purpose and only couples the caller to the tee-cancel's blocking semantic.Provide environment information
Canary verified by inspection only (offending line still present at canary
use-cache-wrapper.ts:2668). Happy to test against a canary build if a maintainer prefers.Which area(s) are affected? (Select all that apply)
Use Cache, Route Handlers, Runtime
Which stage(s) are affected? (Select all that apply)
next dev (local), next start (local), Vercel (Deployed), Other (Deployed)
Additional context
Affects both
next devand deployed Node-runtime route handlers. The Edge runtime path is unaffected becauseedge-route-module-wrapper.tshandspendingWaitUntildirectly toevt.waitUntil(...)instead of awaiting it beforeres.end()(viapipe-readable.ts).Also worth noting that because
pendingRevalidateWritesis awaited inpipe-readable.ts's writer close handler beforeres.end()on the Node runtime, customCacheHandler.setimplementations that correctly return an already-resolved promise (so the lambda/function isn't gated on the persistence write) still see the response blocked — because the block is inside the wrapper itself, beforecacheHandler.set's return value is even relevant. This one-line change is the only thing that unblocks Node-runtime SWR.