Skip to content

use-cache: await ignoredStream.cancel() blocks SWR on Node runtime (waits for source close) #93146

@haydenshively

Description

@haydenshively

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:

  1. App Router route handler that returns JSON via an async "use cache" function whose body takes multiple seconds (e.g. several RPC calls).
  2. cacheLife({ revalidate: 60, expire: 86400 }) on the cached function. Use the default Node runtime.
  3. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions