ReadableStream.tee() Doesn't Backpressure the Way You Think It Does

Rantideb Howlader•27 min read•

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:

text
                 ┌────────────────────┐
                 │  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.

Was this post helpful?

Your feedback helps me improve future content

Subscribe to Newsletter

Get the latest posts delivered right to your inbox

Join 1,000+ readers. No spam, unsubscribe anytime.