nextjs - ✅(Solved) Fix use-cache: `await ignoredStream.cancel()` blocks SWR on Node runtime (waits for source close) [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#93146Fetched 2026-04-24 05:50:45
View on GitHub
Comments
0
Participants
1
Timeline
1
Reactions
0
Participants
Timeline (top)
cross-referenced ×1

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

Root Cause

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.

PR fix notes

PR #93177: use cache: don't block SWR response on background regeneration

Description (problem / solution / changelog)

Summary

await ignoredStream.cancel() at packages/next/src/server/use-cache/use-cache-wrapper.ts:2668 couples the Node-runtime response to the full background regeneration lifetime, turning stale-while-revalidate into "block until fresh". Fixes #93146.

Root cause

ignoredStream is one half of a tee() whose source is renderToReadableStream(resultPromise, …). The other half, savedStream, is actively read by collectResult to populate pendingCacheResult. That keeps the source alive, so Node's Web Streams tee-cancel semantics make ignoredStream.cancel() wait until the source closes, i.e. until regen fully resolves.

The cancel promise lives in revalidatePromise, which is pushed into workStore.pendingRevalidateWrites. executeRevalidates() (revalidation-utils.ts:211-214) folds those into the waitUntilForEnd handed to createWriterFromResponse, whose close callback awaits it before res.end() (pipe-readable.ts:109-120). So on Node the body streams normally but res.end() is held for the full regen duration. On Vercel, where res.end() gates function close, the user-observable latency is exactly the regen time.

The Edge runtime wrapper is unaffected: edge-route-module-wrapper.ts hands pendingWaitUntil directly to evt.waitUntil(...) without awaiting.

Fix

-                  await ignoredStream.cancel()
+                  // Don't await: on Node's Web Streams, cancelling one
+                  // branch of a tee() waits for the source to close, which
+                  // here means waiting for the background regeneration to
+                  // fully resolve. Awaiting couples the SWR branch to the
+                  // regen lifetime, defeating stale-while-revalidate on the
+                  // Node runtime. Fixes #93146.
+                  void ignoredStream.cancel().catch(() => {})

Nothing reads ignoredStream after this point, so dropping the await is safe. The inner .catch(() => {}) silences the detached rejection; the outer .catch on revalidatePromise (for generateCacheEntry / saveToCacheHandler) is retained.

Tests

test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts all 4 cases pass with the fix (NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless …).

Worth calling out: that suite also passes with the bug reintroduced. next.render$ and next.fetch().text() return when the response body is received (~210 ms locally), but the hang sits between end-of-body and res.end(). On the local Node server the TCP connection stays open beyond the harness read window, so duration measurements don't observe it. On Vercel the same path measures as the full regen time because res.end() closes the function.

Tried widening the delay and measuring to connection close; still ~210 ms locally. The symptom really only surfaces in the serverless shape.

Reproduction

  • Route handler returning JSON from an async "use cache" function with a multi-second body.
  • cacheLife({ revalidate: 60, expire: 86400 }), Node runtime.
  • Populate cache, wait > 60 s, hit again.

Expected: stale returned in < 100 ms, background regen. Current: blocks for the full regen, returns fresh value. After this patch: stale returned immediately on Vercel / Node-runtime.

Checklist

  • pnpm --filter=next build clean (types OK)
  • pnpm prettier ... --write + npx eslint ... --fix no-op on the patch
  • test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts 4/4 pass
  • Fixes issue linked
  • Draft per repo guardrails
<!-- NEXT_JS_LLM_PR -->

Changed files

  • packages/next/src/server/use-cache/use-cache-wrapper.ts (modified, +7/-1)

Code Example

$ node tee-cancel-repro.js
b read at 2003 ms, done=false
a.cancel() resolved at 6004 ms   <-- waits for source CLOSE, not cancel

---

-                    await ignoredStream.cancel()
+                    void ignoredStream.cancel().catch(() => {})

---

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
RAW_BUFFERClick to expand / collapse

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.

extent analysis

TL;DR

The proposed fix is to replace await ignoredStream.cancel() with void ignoredStream.cancel().catch(() => {}) to prevent the wrapper from waiting for the regen source to close.

Guidance

  • The issue is caused by Node's Web Streams tee semantics, where cancelling one branch waits until the source stream closes.
  • To fix this, we need to prevent the wrapper from waiting for the regen source to close by not awaiting the cancel() method.
  • The proposed fix is a one-line change that can be applied to the use-cache-wrapper.ts file.
  • To verify the fix, test the application with the modified code and check that the stale value is returned immediately and the regen completes out-of-band.

Example

-                    await ignoredStream.cancel()
+                    void ignoredStream.cancel().catch(() => {})

Notes

  • This fix only applies to the Node runtime and does not affect the Edge runtime.
  • The issue affects both next dev and deployed Node-runtime route handlers.

Recommendation

Apply the proposed workaround by replacing await ignoredStream.cancel() with void ignoredStream.cancel().catch(() => {}) to prevent the wrapper from waiting for the regen source to close. This fix is locally verified and should resolve the issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING