[GH-ISSUE #981] perf: reduce stream tee() allocation from 3 to 2 in ISR cache path #213

Closed
opened 2026-05-06 12:38:13 +02:00 by BreizhHardware · 0 comments

Originally created by @Divkix on GitHub (Apr 30, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/981

Problem

When ISR caching is enabled, a single App Router request can trigger up to 3 ReadableStream.tee() calls, each allocating 2 new ReadableStream objects:

# File:Line Tee Purpose
1 app-page-execution.ts:323 stream.tee() ISR RSC data capture
2 app-ssr-entry.ts:187 rscStream.tee() RSC embed transform for hydration
3 app-page-cache.ts:236 response.body.tee() HTML cache write buffering

Total: up to 5 ReadableStream objects alive simultaneously per request, each with internal buffers. On Cloudflare Workers (128MB hard limit), this creates meaningful memory pressure and GC overhead.

Note: Tee 1 already has a gate (!shouldCapture → skip), so non-ISR requests only have tee 2. The triple-tee only occurs in the cache-enabled path.

What each tee does

Tee 1 (teeAppPageRscStreamForCapture, app-page-execution.ts:312-328):

  • responseStream → passed to SSR handler
  • captureStream → accumulated into ArrayBuffer via readAppPageBinaryStream() for ISR cache write

Tee 2 (SSR embed, app-ssr-entry.ts:187-210):

  • ssrStream → fed to createFromReadableStream() for React SSR
  • embedStream → consumed by createRscEmbedTransform() which injects inline <script> tags progressively

Tee 3 (HTML cache, app-page-cache.ts:236-287):

  • streamForClient → returned to browser with no-store headers
  • streamForCache → fully buffered, then conditionally written to ISR cache (skipped if dynamic usage detected)

Tee 3 must remain separate — it operates on HTML (post-SSR), not RSC, and has a conditional write decision requiring full buffering.

Proposed fix: Fuse Tee 1 + Tee 2

Tee 1 and Tee 2 both consume from the SAME original RSC stream. They can share a single tee:

Current:

RSC stream → tee1 → [responseStream → tee2 → [ssrStream + embedStream]]
                 → [captureStream → accumulate for cache]

Proposed:

RSC stream → fusedTee → [mainBranch → SSR]
                       → [sideBranch → embed transform + capture accumulate]

The side branch runs both the embed transform and binary accumulation from a single stream reader. This means createRscEmbedTransform must also pass through raw chunks to an accumulator.

Estimated files touched: 4

File Change
app-page-execution.ts teeAppPageRscStreamForCapture → produce combined embed+capture from single tee
app-ssr-stream.ts createRscEmbedTransform → also accumulate raw chunks
app-ssr-entry.ts handleSsr → accept combined embed+capture
app-page-render.ts Wire combined pipeline

Difficulty: Medium-Hard

The embed transform is progressive (must inject scripts in lockstep with React SSR), while capture is passive accumulation. The createRscEmbedTransform must be restructured to pass through raw bytes to an accumulator while also producing the embed output.

Expected improvement

~33% less memory during SSR in ISR path. Fewer GC pauses. On Workers, this reduces OOM risk under concurrency.

Risk: Medium

Touches the core SSR rendering pipeline. The embed transform timing is tightly coupled to React's render lifecycle. Must verify no regressions in progressive script injection (which enables out-of-order RSC chunk hydration).

Originally created by @Divkix on GitHub (Apr 30, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/981 ### Problem When ISR caching is enabled, a single App Router request can trigger up to 3 `ReadableStream.tee()` calls, each allocating 2 new ReadableStream objects: | # | File:Line | Tee | Purpose | |---|-----------|-----|---------| | 1 | `app-page-execution.ts:323` | `stream.tee()` | ISR RSC data capture | | 2 | `app-ssr-entry.ts:187` | `rscStream.tee()` | RSC embed transform for hydration | | 3 | `app-page-cache.ts:236` | `response.body.tee()` | HTML cache write buffering | Total: up to 5 ReadableStream objects alive simultaneously per request, each with internal buffers. On Cloudflare Workers (128MB hard limit), this creates meaningful memory pressure and GC overhead. **Note:** Tee 1 already has a gate (`!shouldCapture` → skip), so non-ISR requests only have tee 2. The triple-tee only occurs in the cache-enabled path. ### What each tee does **Tee 1** (`teeAppPageRscStreamForCapture`, `app-page-execution.ts:312-328`): - `responseStream` → passed to SSR handler - `captureStream` → accumulated into `ArrayBuffer` via `readAppPageBinaryStream()` for ISR cache write **Tee 2** (SSR embed, `app-ssr-entry.ts:187-210`): - `ssrStream` → fed to `createFromReadableStream()` for React SSR - `embedStream` → consumed by `createRscEmbedTransform()` which injects inline `<script>` tags progressively **Tee 3** (HTML cache, `app-page-cache.ts:236-287`): - `streamForClient` → returned to browser with `no-store` headers - `streamForCache` → fully buffered, then conditionally written to ISR cache (skipped if dynamic usage detected) Tee 3 must remain separate — it operates on HTML (post-SSR), not RSC, and has a conditional write decision requiring full buffering. ### Proposed fix: Fuse Tee 1 + Tee 2 Tee 1 and Tee 2 both consume from the SAME original RSC stream. They can share a single tee: **Current:** ``` RSC stream → tee1 → [responseStream → tee2 → [ssrStream + embedStream]] → [captureStream → accumulate for cache] ``` **Proposed:** ``` RSC stream → fusedTee → [mainBranch → SSR] → [sideBranch → embed transform + capture accumulate] ``` The side branch runs both the embed transform and binary accumulation from a single stream reader. This means `createRscEmbedTransform` must also pass through raw chunks to an accumulator. ### Estimated files touched: 4 | File | Change | |------|--------| | `app-page-execution.ts` | `teeAppPageRscStreamForCapture` → produce combined embed+capture from single tee | | `app-ssr-stream.ts` | `createRscEmbedTransform` → also accumulate raw chunks | | `app-ssr-entry.ts` | `handleSsr` → accept combined embed+capture | | `app-page-render.ts` | Wire combined pipeline | ### Difficulty: Medium-Hard The embed transform is progressive (must inject scripts in lockstep with React SSR), while capture is passive accumulation. The `createRscEmbedTransform` must be restructured to pass through raw bytes to an accumulator while also producing the embed output. ### Expected improvement ~33% less memory during SSR in ISR path. Fewer GC pauses. On Workers, this reduces OOM risk under concurrency. ### Risk: Medium Touches the core SSR rendering pipeline. The embed transform timing is tightly coupled to React's render lifecycle. Must verify no regressions in progressive script injection (which enables out-of-order RSC chunk hydration).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#213
No description provided.