Skip to content

CONCEPT Cited by 2 sources

Stream adapter overhead

Definition

Stream adapter overhead is the allocation / copy / buffering cost of translating between two streaming API conventions — most commonly Node.js streams (stream.Readable) and Web Streams (ReadableStream) — when a piece of code that wants the one runs on a runtime that prefers the other.

The cost has three components:

  1. Buffer duplication. Each streaming implementation has its own internal queue; bytes flowing through both land in two queues in series.
  2. Allocation churn. Adapters typically wrap each emitted chunk in a small container object / callback closure per hop, creating GC pressure proportional to the chunk rate.
  3. Chunk-boundary thrashing. Default value-oriented ReadableStreams use highWaterMark: 1 — each enqueued value is a separate read, even if the values are bytes that could be coalesced.

Canonical Node ⇆ Web double-buffer

The idiomatic-looking adapter:

const stream = Readable.toWeb(Readable.from(res.getBody()));

is layered as follows:

  • res.getBody()Buffer.concat(chunks) into a single buffer (one copy, one allocation).
  • Readable.from(bufferIterable) — wraps the buffer as a Node.js stream with its own internal ring buffer.
  • Readable.toWeb(nodeReadable) — adapts back into a Web ReadableStream, which has its own internal buffer.

Bytes flow through Node's stream buffer and Web Streams' buffer, plus the initial concat copy.

The OpenNext fix (surfaced by Cloudflare's 2025-10 profiling):

const stream = ReadableStream.from(chunks);

returns a ReadableStream directly from the accumulated chunks without additional copies, extraneous buffering, or passing everything through inefficient adaptation layers.

Value-oriented vs byte-oriented streams

new ReadableStream({ pull() { … } }) without an explicit type is value-oriented. controller.enqueue(buffer) enqueues the whole Buffer / Uint8Array as a single value, and the default highWaterMark is 1 — meaning each enqueue becomes a separate read, regardless of byte count.

In the 2025-10 Cloudflare profiling of React / Next.js, this was real: byte data flowing through value-oriented streams became thousands of tiny reads where a few would have sufficed.

The fix is an explicit type and a reasonable high-water mark:

const readable = new ReadableStream({
  type: 'bytes',
  pull(controller) {
    controller.enqueue(chunks.shift());
    if (chunks.length === 0) {
      controller.close();
    }
  },
}, { highWaterMark: 4096 });

Byte-oriented streams coalesce internally up to highWaterMark bytes, so consumers pull coalesced 4 KiB blocks instead of individual small chunks.

Why it compounds on hot paths

HTTP response rendering is a concepts/hot-path at CDN / SSR scale. Each chunk / each value that passes through a stream adapter runs once per response, and large-response benchmarks (the 2025-10 case is a 5 MB response) multiply the per-chunk cost.

Cloudflare observed 10-25 % of request processing time in GC on the Next.js / OpenNext benchmark — a disproportionate share driven by adapter-induced allocation churn (plus other sources profiled separately). Removing the adapter layer or flipping to byte-oriented streams cut much of it.

Mitigations

  • Skip the adapter when you already have the shape you want. If bytes-in-hand, ReadableStream.from(chunks) instead of Readable.toWeb(Readable.from(...)).
  • Use byte-oriented ReadableStreams with a sensible highWaterMark (e.g. 4096) for byte data.
  • Respect back-pressure end-to-end. Cloudflare notes that parts of the Next.js / React rendering pipeline don't use byte streams or back-pressure signals well; fixes in the platform layer can only recover some of that.
  • Pay attention to the interface between ecosystems. The cost pattern is Node ⇆ Web here, but generalizes to any two streaming APIs with their own buffering assumptions (gRPC streaming ⇆ Kafka, Kafka ⇆ RocksDB iterators, etc).

Sibling concept

concepts/head-of-line-buffering is the same shape at the HTTP-proxy / middleware layer — streaming-hostile defaults silently collapse chunked streams back into blobs (nginx proxy_buffering, compression middleware, objectMode transforms). Stream adapter overhead is the in-process version of the same pathology.

Seen in

Last updated · 200 distilled / 1,178 read