---
title: "ReadableStream.tee() Doesn't Backpressure the Way You Think It Does"
author: "Rantideb Howlader"
date: "2026-07-29T00:00:00.000Z"
canonical_url: "https://ranti.dev/blog/stream-tee-backpressure"
license: "CC-BY-4.0"
---


`tee()` forks a stream into two branches.

Most developers assume each branch gets its own independent backpressure loop: read one slowly, read the other fast, and the runtime sorts out memory on its own.

That assumption is wrong.

Both branches share a single pull loop against the underlying source.

That loop is driven by whichever branch's consumer is actively calling `read()`, which in practice usually means the faster consumer ends up driving progress.

A branch you forget to read, or read slowly, keeps receiving chunks anyway.

Nothing in the tee algorithm considers the unread branch's queue when deciding whether to keep pulling from the source.

The behavior follows directly from the Streams Standard: the same pull algorithm, queue semantics, and `desiredSize` rules apply in every engine that implements it.

The spec does not mandate identical memory graphs across engines, since allocation strategy is an implementation detail, but the algorithmic cause described here, one shared pull driven by whichever branch reads fastest, is specification behavior, not a browser quirk.

It is also a common, invisible cause of memory spikes in code that clones a fetch response, tees a file upload, or forks a stream for logging while processing the original.

This post covers the internal queue model streams use, the exact algorithm `tee()` runs, why the memory growth happens, how to reproduce it on demand, how it differs between browsers and Node, and two production fixes with different trade-offs.

## Background: how a single ReadableStream manages its queue

Before `tee()` makes sense, the internal queue model of a single stream needs to be explicit, because `tee()` reuses it twice over.

Every `ReadableStream` has an internal queue, a `highWaterMark`, and a `desiredSize`.

These three work together:

| Term            | What it is                                                                                                                                                          |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Internal queue  | The list of chunks the stream has pulled from its source but the consumer has not read yet.                                                                         |
| `highWaterMark` | A target ceiling for the queue, expressed in chunk count by default, or in bytes if you supply a `size()` function.                                                 |
| `desiredSize`   | `highWaterMark` minus the current queue size. Positive means there is room. Zero or negative means the consumer should slow down or the source should stop pushing. |

A well-behaved underlying source checks `controller.desiredSize` before enqueuing more data:

```typescript
function makeBackpressureAwareSource(socket: { read(): Promise<Uint8Array | null> }) {
  return new ReadableStream<Uint8Array>({
    async pull(controller) {
      const chunk = await socket.read();
      if (chunk === null) {
        controller.close();
        return;
      }
      controller.enqueue(chunk);
      // Checking desiredSize here is informational, not enforcement. The
      // stream implementation is what decides whether to call pull() again;
      // it will not, until the consumer reads enough to bring desiredSize
      // back above zero. Nothing about calling enqueue() here is gated
      // by this check, the stream machinery handles that scheduling for you.
    },
  });
}
```

This is the mechanism the spec calls backpressure.

`pull()` only fires again once `desiredSize` climbs back above zero, which happens when the consumer reads a chunk out of the queue.

One queue, one producer, one consumer, one clean signal.

`tee()` breaks the one-to-one part of that model while keeping only one producer.

## Step 1: what tee() actually sets up

Call `stream.tee()` and the spec algorithm, `ReadableStreamDefaultTee` in the Streams Standard, does the following:

1. Creates a single shared reader on the original stream.

2. Creates two new `ReadableStream` objects, branch A and branch B, each with its own independent internal queue and its own `highWaterMark`.

3. Defines one shared pull algorithm used by both branches.

   There is one pull function, not two.

4. On every invocation, that shared pull algorithm reads one chunk from the original reader and enqueues it into both branch queues, as long as the respective branch has not been canceled.

5. Defines a shared cancel algorithm.

   Canceling branch A records that cancellation but does not cancel the source.

   The source is only canceled once both branches have been canceled.

Step 4 is the part that surprises people.

The pull algorithm decides whether to request the next chunk based on the reader that is driving it, which in practice means whichever branch's consumer is actively calling `read()`.

It does not check `desiredSize` on both branches before deciding to pull.

It only checks whether a branch has been canceled.

Read status and cancel status are different things, and only cancel status stops the enqueue into a given branch's queue.

The shared pull function itself carries two internal flags, `reading` and `readAgain`, that most descriptions of `tee()` skip over.

They exist to stop the pull from re-entering itself:

- When a read against the original source is already in flight, `reading` is true.

  If a second `read()` call arrives on either branch while that first read is still pending, the algorithm does not start a second concurrent read against the shared source.

  It sets `readAgain` to true instead.

- Once the in-flight read resolves and both chunks are enqueued, the algorithm checks `readAgain`.

  If it was set, it immediately loops and starts another read, rather than waiting for a fresh `pull()` invocation from the stream machinery.

The practical effect: no matter how many times you hammer `read()` on both branches simultaneously, only one read against the underlying source is ever in flight at once.

That is good for correctness, it stops the same chunk from being read twice out of the original source, but it does not change the core issue.

It still enqueues into both branch queues on every resolved read, unconditionally with respect to the neglected branch's `desiredSize`.

Here is the shape of it laid out as a diagram:

```
                 ┌────────────────────┐
                 │  underlying source │
                 └──────────┬─────────┘
                            │  single shared reader
                            │  single shared pull()
                            ▼
                 ┌────────────────────┐
                 │   tee() dispatch   │
                 └─────┬────────┬─────┘
                       │        │
           enqueue     │        │     enqueue
     (regardless of    │        │   (regardless of
      branch A state)  ▼        ▼    branch B state)
              ┌──────────┐  ┌──────────┐
              │ branch A │  │ branch B │
              │  queue   │  │  queue   │
              └────┬─────┘  └────┬─────┘
                   │             │
              consumer A    consumer B
             (drives pull)  (may lag or
                              never read)
```

Whichever consumer calls `read()` triggers the shared pull, and that single pull writes into both queues.

If consumer A reads in a tight loop and consumer B never reads, branch B's queue grows by one chunk for every chunk consumer A pulls through.

In the worst case, where consumer A drains the stream to completion, branch B ends up holding the remainder of the entire body.

A node-fetch maintainer documented this directly (github.com/node-fetch/node-fetch/issues/1568) while explaining why `Response.clone()` behaves differently in Node than in browsers.

In short: browser fetch backpressures according to the faster branch, and if one branch is never read, the browser can end up buffering the remainder of the body in memory rather than pausing the network read.

## What actually gets retained in memory

Whether the two branches share the exact same chunk object or each get their own copy is not fixed.

It depends on an argument most code never sees, because `stream.tee()` hides it.

The underlying spec operation is `ReadableStreamTee(stream, cloneForBranch2)`.

The public `tee()` method you call from JavaScript always invokes it with `cloneForBranch2` set to false.

Both branches then enqueue the identical chunk reference, meaning the same `Uint8Array` view over the same `ArrayBuffer`, with no copy taken at all.

This is true at the level of what your code can observe: read the same chunk out of both branches and `===` on the two results is true.

An engine is free to do something cleverer internally as long as it preserves that observable identity.

`Response.clone()` does not go through the public `tee()` method.

The Fetch specification calls the same internal `ReadableStreamTee` operation directly, but with `cloneForBranch2` set to true.

That means the second branch of a cloned response, the one you get back from `.clone()`, is logically cloned according to the structured clone algorithm on every chunk, not passed by reference to the original.

Engines are free to optimize the actual allocation, so this should not be read as a promise that every implementation literally invokes a JS-visible `structuredClone()` call per chunk, only that the observable result matches structured-clone semantics: mutating a chunk on one branch cannot affect the other.

The Node.js `webstreams` documentation states the split explicitly: `tee()` always passes false, while other web platform specs such as Fetch body cloning pass true so that the second branch receives cloned chunks and consumption of one branch cannot mutate chunks seen by the other.

The motivation is not backpressure at all, it predates this discussion.

`cloneForBranch2` exists because `Response.clone()` needs each branch to be safely and independently consumable, including by code that transfers a chunk's underlying buffer to a Worker via `postMessage`.

If both branches held the same chunk object, transferring it out from under one branch would detach the `ArrayBuffer` the other branch is still holding a reference to.

The WHATWG streams issue that introduced this parameter frames it exactly that way: cloning avoids branches interfering with each other when consumers do things like transfer their chunks.

The backpressure cost described below is a side effect of that safety guarantee, not its purpose.

That distinction changes the memory story in two different ways depending on which path you took:

| Call path               | `cloneForBranch2` | What branch 2's queue holds                                                                                                                                                            |
| ----------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stream.tee()` directly | `false`           | The same chunk objects as branch 1. No extra bytes allocated for the clone itself, but a neglected branch still keeps every referenced chunk alive and unable to be garbage collected. |
| `response.clone()`      | `true`            | Independent structured-clone copies. A neglected second branch does not just retain references, it holds a second, fully duplicated copy of every chunk that has flowed through.       |

So a neglected branch from `response.clone()` is strictly worse than a neglected branch from a raw `stream.tee()` call.

The unread branch is not just pinning shared data in memory, it is holding a complete, separately allocated second copy of the body, built one `structuredClone()` per chunk as the shared pull fires.

This also means the two costs described earlier compound differently depending on which API produced the branches:

1. Retention, not duplication, is still retention.

   For a raw `tee()`, as long as branch B's queue holds a reference to a chunk, that chunk cannot be garbage collected, even after branch A has consumed and discarded its own reference to the same data.

2. `CountQueuingStrategy`, the default strategy when you do not pass a `size()` function, counts one unit per chunk regardless of byte size.

   A `highWaterMark` of 1 on a stream of 1MB chunks still lets a single 1MB chunk sit in the queue, and the shared pull can keep enqueuing past that, because the check that matters is the reading branch's desired size, not the neglected branch's.

3. For `response.clone()` specifically, add the cost of the `structuredClone()` call itself on every chunk destined for the second branch, whether or not anything ever reads it.

For byte-oriented data, use `ByteLengthQueuingStrategy` or a custom `size()` function so `desiredSize` reflects actual memory pressure instead of chunk count:

```typescript
const stream = new ReadableStream<Uint8Array>(
  {
    /* underlying source */
  },
  new ByteLengthQueuingStrategy({ highWaterMark: 16 * 1024 * 1024 }) // 16MB
);
```

This makes the _reading_ branch's backpressure accurate.

It does nothing for a branch that is not being read at all, because desired size only matters to the shared pull algorithm when it belongs to the branch currently driving the reads.

## Step 2: reproduce it

You do not need a real network response to see the effect.

A synthetic producer shows it in a few seconds and makes the numbers exact.

```typescript
function makeChunkProducer(totalChunks: number, chunkBytes = 1_048_576) {
  let produced = 0;
  return new ReadableStream<Uint8Array>({
    pull(controller) {
      if (produced >= totalChunks) {
        controller.close();
        return;
      }
      controller.enqueue(new Uint8Array(chunkBytes));
      produced++;
    },
  });
}
```

Steps to run the reproduction:

1. Create the producer with 500 chunks of 1MB each, 500MB total.

2. Call `tee()` to get `fast` and `slow`.

3. Get a reader on each branch.

4. Drain `fast` in a tight loop with no delay.

5. Read exactly one chunk from `slow`, then stop reading it entirely.

6. Wait for `fast` to finish and inspect memory.

```typescript
async function demonstrateDivergence() {
  const source = makeChunkProducer(500);
  const [fast, slow] = source.tee();

  const fastReader = fast.getReader();
  const slowReader = slow.getReader();

  let fastChunks = 0;
  const fastDone = (async () => {
    while (true) {
      const { done } = await fastReader.read();
      if (done) break;
      fastChunks++;
    }
  })();

  await slowReader.read(); // read once, then abandon this branch

  await fastDone;
  console.log(`Fast branch drained ${fastChunks} chunks.`);
  console.log("Slow branch has consumed 1 of 500 chunks the source already produced.");
}
```

To confirm the memory retention rather than just trust the log line, use one of these:

- Chrome DevTools, Memory panel, Heap snapshot.

  Take a snapshot before running the function, run it, take a second snapshot, and use the comparison view filtered to `Uint8Array` and `ArrayBuffer`.

  Retained size will show roughly 499MB attributable to the unread branch's internal queue.

- `performance.measureUserAgentSpecificMemory()`, available in cross-origin isolated contexts in Chromium browsers, for a coarse before-and-after JS heap estimate.

- In Node, run with `--expose-gc`, call `global.gc()` before and after, and diff `process.memoryUsage().arrayBuffers`.

## Step 3: why cancel() does not save you by itself

The instinctive fix is to cancel the branch you are not using:

```typescript
const [keep, discard] = stream.tee();
discard.cancel();
```

This only works under two conditions:

1. You call it before any chunks accumulate.

2. You call it synchronously, in the same tick you created the branches, with no `await` in between.

Canceling one branch does not cancel the underlying source.

Per the shared cancel algorithm in step 5 above, the source stays alive until both branches are canceled, or until the stream closes on its own.

That is correct behavior for the common case, where you want the other branch to keep working after you drop one.

But it means a delayed `cancel()` call leaves a window open where chunks pile up in the branch you are about to discard.

A few related edge cases worth knowing:

- If branch A is canceled while branch B is actively being read, the source keeps running for branch B's benefit, exactly as intended.

  There is no race condition in the sense of corrupted state, the shared cancel algorithm simply records branch A as canceled and stops delivering chunks to it, while branch B's reads continue against the same underlying reader as before.

  The only thing to watch for is timing: if you cancel branch A expecting it to free memory immediately, it does, canceling a branch does drop that branch's own queue, but it does nothing to slow down branch B's queue if branch B is itself falling behind.

- If the underlying source errors, both branches receive the error on their next read, and both queues are cleared.

  An erroring source does not leave stale chunks behind.

- `tee()` throws a `TypeError` if the stream is already locked, meaning a reader is already attached.

  Release any existing lock with `reader.releaseLock()` before calling `tee()`, or tee before acquiring a reader in the first place.

- Byte streams created with `type: "bytes"` use a separate algorithm, `ReadableByteStreamTee`, which supports BYOB (bring your own buffer) reads on each branch independently.

  The same shared-pull, no-cross-branch-backpressure-check behavior applies.

  BYOB does not change the fundamental issue, it only changes how each branch's own queue is populated.

## Interaction with pipeTo() and pipeThrough()

`pipeTo()` respects backpressure correctly for the pipe it is running.

It pauses reading from its source reader whenever the destination writer's `desiredSize` drops to zero or below.

That is real, working backpressure, but it is scoped to a single reader and a single writer.

Piping one tee branch through `pipeTo()` does not extend that backpressure signal back through the shared tee pull to the other branch.

If you do this:

```typescript
const [branchA, branchB] = stream.tee();

branchA.pipeTo(slowDestination); // correctly pauses when slowDestination is full
// branchB just sits there, unread, accumulating everything the shared pull produces
```

`branchA`'s pipe will correctly slow down when `slowDestination` is not keeping up.

That slowdown does reduce how often the shared pull fires, since `branchA`'s reader is the one driving it in this scenario.

But if `branchB` also has an active reader pulling independently, or if something else is driving the shared pull faster than `branchA`'s pipe wants, `branchB` still accumulates without limit.

`pipeTo()` fixes backpressure for one leg.

It does not coordinate across both.

## Edge cases worth knowing before you ship this

A handful of related behaviors do not fit cleanly into the sections above but change how the bug shows up in real code.

**`for await...of` is still `read()` underneath.**

`ReadableStream` is async-iterable, so code written as:

```typescript
for await (const chunk of stream) {
  process(chunk);
}
```

compiles down to the same `getReader()` and repeated `read()` calls used throughout this post.

It does not sidestep anything described here.

A tee branch consumed with `for await` is exactly as vulnerable to the shared-pull problem as one consumed with an explicit reader loop.

**Aborting the fetch errors both branches.**

If the original request used an `AbortSignal` and it fires mid-stream, both tee branches transition to an errored state on their next read, the same way they would if the network connection itself failed.

Any code holding a reference to an unread branch should treat an abort as a valid, expected way for that branch to end, not just stream completion or explicit cancellation.

**Partial consumption is not the same as no consumption.**

Reading the first 10% of a branch and then stopping still leaves that branch's queue accumulating for the remaining 90%, exactly as if it had never been read at all.

There is no partial credit.

The moment you stop calling `read()` on a branch, whatever the source produces from that point forward piles up in that branch's queue until you resume, cancel, or the stream ends.

**Chaining a `TransformStream` after a tee branch adds another queue, not a bypass.**

A common pattern pipes one tee branch through a `TransformStream` before final consumption:

```typescript
const [raw, forCache] = response.body!.tee();
raw.pipeThrough(new TransformStream({ transform: decompressChunk })).pipeTo(finalDestination);
```

The `TransformStream` has its own readable and writable sides, each with their own internal queue and `highWaterMark`.

If `finalDestination` is slow, backpressure propagates correctly back through the transform to `raw`'s reader, and from there to the shared tee pull, same as the plain `pipeTo()` case earlier.

But `forCache` is unaffected by any of this.

It is a second, entirely separate accumulation point, and now there are three queues in play instead of one: the transform's internal queue, `raw`'s tee branch queue, and `forCache`'s tee branch queue.

**Decompressed size, not wire size, is what fills the queue.**

If the body is compressed and you decompress it, for instance by piping through `DecompressionStream`, the chunks landing in a neglected branch's queue are measured against whatever queuing strategy you configured, and that strategy operates on the decompressed bytes if decompression happens before the tee, or the compressed bytes if it happens after.

A response that looks small on the wire can still produce a large, fast-growing queue if you tee after decompressing.

Tee before decompressing when you can, so a neglected branch accumulates compressed bytes instead of inflated ones.

**`Response.bodyUsed` catches a related but distinct mistake.**

Trying to read a `Response` body a second time without cloning or teeing first throws, and `response.bodyUsed` reports whether that has already happened.

This is not the bug described in this post, a `bodyUsed` check does not tell you anything about queue growth on a tee branch, but it is the error most developers actually encounter first, right before they reach for `.clone()` and inherit the behavior above.

## Step 4: implement a bounded fix with concurrent draining

The reliable fix is to stop treating the branches as independent.

Pump both concurrently so neither one is starved relative to the other.

1. Tee the stream and get both readers up front, in the same tick.

2. Write a pump function that reads a chunk, hands it to a consumer callback, and loops.

3. Start both pumps at the same time with `Promise.all`, not one after the other.

```typescript
async function teeWithBoundedMemory(
  stream: ReadableStream<Uint8Array>,
  consumerA: (chunk: Uint8Array) => Promise<void>,
  consumerB: (chunk: Uint8Array) => Promise<void>
): Promise<void> {
  const [a, b] = stream.tee();
  const readerA = a.getReader();
  const readerB = b.getReader();

  async function pump(
    reader: ReadableStreamDefaultReader<Uint8Array>,
    consume: (chunk: Uint8Array) => Promise<void>
  ) {
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) return;
        await consume(value);
      }
    } finally {
      reader.releaseLock();
    }
  }

  await Promise.all([pump(readerA, consumerA), pump(readerB, consumerB)]);
}
```

Both readers issue `read()` on every iteration instead of one branch sitting idle while the other runs to completion.

This bounds the imbalance to roughly one in-flight chunk per branch instead of the entire remaining body, because whichever reader is behind is the one still being awaited, and the shared pull cannot get far ahead of the slower of the two active readers.

## Step 5: a hand-rolled fan-out that actually respects both consumers

The concurrent pump bounds the damage but still inherits `tee()`'s shared pull, which reacts to whichever reader happens to call `read()` next rather than to the true minimum `desiredSize` across both branches.

For cases where you need real multicast backpressure, meaning the source itself pauses until both consumers have room, skip `tee()` and drive the queue yourself:

```typescript
function fanOutWithSharedBackpressure(
  source: ReadableStream<Uint8Array>,
  highWaterMarkBytes: number
): [ReadableStream<Uint8Array>, ReadableStream<Uint8Array>] {
  const sourceReader = source.getReader();
  let controllerA: ReadableStreamDefaultController<Uint8Array>;
  let controllerB: ReadableStreamDefaultController<Uint8Array>;

  // Both branches' pull() calls point at this same function, so without a
  // guard, two near-simultaneous pulls could each call sourceReader.read()
  // before either resolves. That double-reads the source, the same failure
  // the spec's own tee algorithm avoids with its `reading` / `readAgain`
  // flags described earlier. This mirrors that guard.
  let reading = false;
  let pullAgain = false;

  async function pullBoth() {
    const sizeA = controllerA.desiredSize ?? 0;
    const sizeB = controllerB.desiredSize ?? 0;
    // Only ask the source for more once BOTH branches have room.
    if (sizeA <= 0 || sizeB <= 0) return;

    if (reading) {
      pullAgain = true;
      return;
    }
    reading = true;

    const { done, value } = await sourceReader.read();
    reading = false;

    if (done) {
      controllerA.close();
      controllerB.close();
      return;
    }
    controllerA.enqueue(value);
    controllerB.enqueue(value);

    if (pullAgain) {
      pullAgain = false;
      await pullBoth();
    }
  }

  const strategy = new ByteLengthQueuingStrategy({ highWaterMark: highWaterMarkBytes });

  const branchA = new ReadableStream<Uint8Array>(
    {
      start(c) {
        controllerA = c;
      },
      pull: pullBoth,
    },
    strategy
  );
  const branchB = new ReadableStream<Uint8Array>(
    {
      start(c) {
        controllerB = c;
      },
      pull: pullBoth,
    },
    strategy
  );

  return [branchA, branchB];
}
```

This version checks `desiredSize` on both controllers before pulling from the source, and guards the read itself with the same `reading` / `pullAgain` pattern the spec's own tee algorithm uses, so two near-simultaneous pulls never issue overlapping reads against `sourceReader`.

If either branch's queue is full, the source read is skipped for that pull cycle, and the stream machinery will call `pull()` again once whichever branch was behind drains.

This is genuine two-consumer backpressure, at the cost of writing and maintaining the fan-out logic yourself instead of relying on the built-in `tee()`.

## Step 6: skip tee() entirely when you would buffer anyway

For the cache-then-process case specifically, buffering once is simpler and more predictable than teeing at all:

1. Fetch the response.

2. Read the full body once into an `ArrayBuffer`.

3. Build two new `Response` objects from that single buffer.

4. Hand one to the cache, use the other for processing.

```typescript
async function fetchAndCacheBuffered(request: Request): Promise<Response> {
  const response = await fetch(request);
  const buffer = await response.arrayBuffer();

  const init: ResponseInit = {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  };

  const cache = await caches.open("v1");
  await cache.put(request, new Response(buffer.slice(0), init));

  return new Response(buffer, init);
}
```

You still hold the whole body in memory, but as one bounded, predictable allocation instead of an unbounded queue whose size depends on a race between two consumers you do not control the timing of.

## Runtime differences

Older versions of `node-fetch`, the widely used polyfill from before Node shipped a built-in `fetch`, did not implement the Fetch Standard's streaming body on top of the web streams primitives described here.

It built response bodies on Node's classic `stream.Readable` instead, which has its own, older backpressure model based on a fixed internal buffer and a `push()` return value.

That is a different implementation with a different failure mode, not the same `ReadableStreamDefaultTee` algorithm running differently.

It backpressured according to the slower branch and would hang once its buffer, commonly 32KiB, filled if one branch was never read, the opposite symptom from what a spec-compliant `tee()` produces, but the same underlying root cause: one producer, two independent consumers, no shared coordination between their queues.

| Runtime                                                 | Behavior when one tee branch goes unread                                                                                                                                                                                                                                              |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Chromium, Firefox, Safari (spec-compliant `fetch` body) | Shared pull continues as long as the other branch keeps demanding data. The unread branch's queue grows in step, and can end up holding the remainder of the body if the other branch runs to completion.                                                                             |
| Node.js `node-fetch` (pre-Undici, older versions)       | Built on Node's classic `stream.Readable`, not web streams. Backpressures according to the slower branch and hangs once its internal buffer fills if one branch is never read. Different mechanism, same class of bug.                                                                |
| Node.js built-in `fetch` (Undici-based, current)        | Implements the same Streams Standard algorithms described in this post. Implementation details such as allocation strategy and internal buffering may still differ from a given browser engine's build.                                                                               |
| Deno                                                    | Implements the Streams Standard directly. Same algorithmic behavior as browsers, with the same caveat about allocation-level implementation differences.                                                                                                                              |
| Bun                                                     | Ships its own Web Streams implementation rather than reusing V8's or WebKit's. It targets the same spec algorithms, but given how young and fast-moving Bun's streams implementation is, verify tee behavior against the specific Bun version you deploy rather than assuming parity. |

The practical takeaway from this table: do not assume a fix validated in a browser environment behaves identically under an older Node fetch polyfill, and do not assume the reverse either.

Test the specific runtime and fetch implementation you deploy against.

## Choosing a fix

| Approach                                              | Memory behavior                                                                             | Implementation cost                      | Best for                                                                                                |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Naive `tee()`, one branch read lazily or never        | Effectively unbounded relative to stream size, grows with the gap between the two consumers | None, this is the default and the bug    | Never                                                                                                   |
| `tee()` with concurrent pump (Step 4)                 | Bounded to roughly one chunk per branch                                                     | Low, a wrapper around two `read()` loops | Most cases where both branches are read to completion                                                   |
| Hand-rolled fan-out with shared backpressure (Step 5) | Bounded by `highWaterMark`, true multicast pressure                                         | Higher, you own the pull logic           | Long-lived or very large streams, video, large downloads, where precise memory bounds matter            |
| Buffer once, build two Responses (Step 6)             | One fixed allocation equal to the full body size                                            | Low                                      | Small to medium bodies you would buffer on one side anyway, such as JSON API responses cached for later |

## Production checklist

- Never call `tee()` and defer reading one branch past the current tick.

- If you cancel a branch, cancel it synchronously, before any `await`.

- If both branches must be read, drive them with `Promise.all`, not sequentially.

- Use a byte-aware queuing strategy, `ByteLengthQueuingStrategy` or a custom `size()`, for any stream carrying binary payloads, so `desiredSize` reflects real memory rather than chunk count.

- For payloads under a few megabytes, prefer buffering once over teeing.

- For large or streaming payloads with two long-lived consumers, use the hand-rolled fan-out pattern instead of `tee()`.

- Profile heap retained size under a deliberately slow-consumer scenario before shipping any code that tees a stream, not just under the happy path where both consumers keep up.

## Further reading

- Streams Standard, `ReadableStreamDefaultTee` and `ReadableByteStreamTee`: streams.spec.whatwg.org

- MDN, Streams API concepts, backpressure section: developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts.

  MDN documents that an unread branch keeps accumulating data internally, which is the right high-level warning, but it does not walk through `ReadableStreamDefaultTee`'s shared pull, the `reading`/`readAgain` guard, or the `cloneForBranch2` split covered above, which is the part that explains why the warning is true.

- node-fetch issue #1568, browser versus Node tee backpressure divergence: github.com/node-fetch/node-fetch/issues/1568

- Node.js Web Streams API docs, `cloneForBranch2` parameter on the internal tee operation: nodejs.org/api/webstreams.html

- whatwg/streams issue #528, the discussion that introduced `cloneForBranch2` so Fetch body cloning could avoid cross-branch mutation: github.com/whatwg/streams (issue 528)

## Frequently asked questions

**Why does `ReadableStream.tee()` use more memory than expected?**

Because both branches share one pull loop driven by whichever branch is read fastest.

A branch that is read slowly, or not at all, keeps receiving chunks with no signal back to the source to slow down, so its queue grows independently of how much you have actually consumed from it.

**Is `Response.clone()` safe for large downloads?**

Only if you read both resulting responses at a matched pace, or buffer the body once instead of cloning it.

`Response.clone()` tees internally with `cloneForBranch2` set to true, so a neglected branch does not just retain shared references, it accumulates independently allocated copies of every chunk.

**Does `tee()` duplicate bytes?**

Not for a plain `stream.tee()` call.

Both branches share the same chunk objects.

`Response.clone()` is the exception: it invokes the same internal tee operation with cloning enabled, so its second branch does hold independent copies.

**Why doesn't backpressure stop the unread branch?**

`desiredSize` only affects the shared pull algorithm when it belongs to whichever branch is currently driving reads.

A branch nobody is reading from has no active reader triggering pulls, so its `desiredSize` going negative has no mechanism to act on it.

**Should I use `tee()` for logging or telemetry alongside a main consumer?**

Only if the logging branch is drained at roughly the same pace as the main one, ideally with the concurrent-pump pattern described above.

A telemetry branch that batches or delays its reads is exactly the shape of consumer that triggers this problem.

**When should I buffer instead of teeing?**

For payloads you would fully buffer on at least one side anyway, such as caching a JSON response, buffering once and constructing two `Response` objects from that buffer is simpler and gives you a fixed, predictable allocation instead of an unbounded queue.

## The takeaway

`tee()` gives you two streams but one pull loop.

Treat the branches as coupled, not independent.

Either drain them concurrently at a matched pace, hand-roll a fan-out that checks both branches' desired size before pulling, or skip teeing altogether and buffer once.

This failure mode does not throw and does not warn.

It shows up as a heap graph that climbs in proportion to how uneven your two consumers' read rates are, which is exactly the kind of thing that only surfaces under real production load.


---

<!-- METADATA_START -->
## Metadata & Citations

### Further Reading
- [Build Your Own HTTP Server](https://ranti.dev/blog/build-your-own-http-server.md)
- [JavaScript Has a Flair for Drama: 6 Concepts That Break Minds and this](https://ranti.dev/blog/javascript-concepts-explained.md)
- [My First Month as a Site Reliability Engineer at Airbnb](https://ranti.dev/blog/airbnb-sre-first-month.md)

### Navigation
- [Back to Bio Hub](https://ranti.dev/.md)
- [Full Site Manifest](https://ranti.dev/llms.txt)

```json
{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "ReadableStream.tee() Doesn't Backpressure the Way You Think It Does",
  "author": {
    "@type": "Person",
    "name": "Rantideb Howlader"
  },
  "datePublished": "2026-07-29T00:00:00.000Z",
  "url": "https://ranti.dev/blog/stream-tee-backpressure",
  "license": "https://creativecommons.org/licenses/by/4.0/",
  "isAccessibleForFree": true
}
```

### BibTeX
```bibtex
@article{stream-tee-backpressure_2026,
  author = {Rantideb Howlader},
  title = {ReadableStream.tee() Doesn't Backpressure the Way You Think It Does},
  journal = {Rantideb Howlader Portfolio},
  year = {2026},
  url = {https://ranti.dev/blog/stream-tee-backpressure},
  note = {Accessed: 2026-09-07}
}
```

### IEEE
Rantideb Howlader, "ReadableStream.tee() Doesn't Backpressure the Way You Think It Does," Rantideb Howlader Portfolio, 2026. [Online]. Available: https://ranti.dev/blog/stream-tee-backpressure. [Accessed: 2026-09-07].

### APA
Rantideb Howlader. (2026). ReadableStream.tee() Doesn't Backpressure the Way You Think It Does. Rantideb Howlader. Retrieved from https://ranti.dev/blog/stream-tee-backpressure

--- 
*This content is provided in research-grade Markdown format. Required Attribution: Cite as Rantideb Howlader (2026).*
<!-- METADATA_END -->