Skip to content

SYSTEM Cited by 2 sources

Web Streams API

Web Streams (MDN) is the WHATWG Streams StandardReadableStream, WritableStream, TransformStream. Designed 2014-2016 as a common cross-runtime streaming API, now the substrate under fetch() bodies in browsers, Deno, Bun, and Cloudflare Workers. Node.js has it too (via Readable.toWeb / Writable.toWeb) but most older Node code uses Node's own earlier stream.Readable / stream.Writable API.

Stream types

Two flavours of ReadableStream:

  • Value-oriented (default): enqueued items are arbitrary JS values. Default highWaterMark: 1 — one enqueue is one read. This is what you get with new ReadableStream({ pull() { … } }) without an explicit type.
  • Byte-oriented: type: 'bytes'. The controller can coalesce enqueued Uint8Array / Buffer chunks internally; reads pull up to highWaterMark bytes across however many underlying chunks satisfy the request.

The 2025-10 Cloudflare-benchmark post flagged React and Next.js as passing bytes through value-oriented streams with default highWaterMark: 1 — so a stream of 1000 × 1-byte chunks became 1000 round trips into the stream instead of one ~4 KiB coalesced read. Explicit fix: declare type: 'bytes' + a reasonable highWaterMark (e.g. 4096).

The 2026-02-27 structural critique

James Snell — Cloudflare Workers runtime engineer and Node.js TSC member — published a structural critique arguing Web streams' usability and performance issues cannot be fixed with incremental improvements; they are consequences of foundational design choices made 2014-2016, before async iteration landed in ES2018. See sources/2026-02-27-cloudflare-a-better-streams-api-is-possible-for-javascript for the full treatment. Key points:

Excessive ceremony for common operations

Reading a stream to completion the spec way:

const reader = stream.getReader();
const chunks = [];
try {
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    chunks.push(value);
  }
} finally {
  reader.releaseLock();
}

— vs the same thing through the async-iteration retrofit:

const chunks = [];
for await (const chunk of stream) chunks.push(chunk);

The async-iteration form is better, but the underlying complexity (readers, locks, controllers) is still there hidden — and BYOB reads can't be accessed through iteration, so zero-copy consumers must fall back to the manual reader loop.

Locks are footguns

getReader() acquires an exclusive lock; forgetting releaseLock() permanently breaks the stream. The locked property tells you that a stream is locked, not why, by whom, or whether the lock is even still usable. Piping internally acquires locks, making streams silently unusable during pipeTo() in ways that aren't obvious.

BYOB ships complexity without payoff

BYOB reads require a separate reader type, separate controller, ArrayBuffer detachment semantics, and dedicated WPT test files for edge cases (detached buffers, bad views, WebAssembly memory rejection). Most userland ReadableStream implementations don't bother with the dual default/BYOB path; most consumers stick with default reads; BYOB can't compose with async iteration or TransformStream.

Backpressure is advisory-only

controller.desiredSize exposes a signal, but controller.enqueue() always succeeds regardless — producers can and do ignore it. writer.ready exists on the writable side but is routinely ignored. tee() branches buffer unboundedly when one side reads faster than the other; the spec mandates no buffer limit. See concepts/backpressure for the structural discussion.

Per-operation promise allocation

Every read() creates a promise; internally, spec-mandated coordination promises exist for queue management, pull() coordination, backpressure signaling. At streaming SSR scale (hot paths running per-request), short-lived allocation churn lands as 10-25 % of request CPU in GC, per the 2025-10 measurement — up to 50 %+ per the 2026-02 post. See concepts/promise-allocation-overhead.

Unconsumed bodies leak connections

If you call fetch(url) and only check response.ok without consuming or cancelling the body, the stream holds a reference to the underlying connection until GC runs. This has caused connection pool exhaustion in Node's undici. Request.clone() and Response.clone() implicitly tee(), compounding this.

TransformStream is push-based

transform() runs on write, not on read. Synchronous transforms that always controller.enqueue() never signal back-pressure to the writable side, even when the readable side's buffer is full. A 3-transform chain can fill six internal buffers simultaneously before the consumer starts reading. See concepts/pull-vs-push-streams.

Optimization treadmill is unsustainable

Every major runtime has invented non-standard internal escape hatches to make Web streams fast:

Runtime Escape hatch
Node.js Vercel's proposed fast-webstreams (10× promise elision)
Deno Native-path optimizations
Bun "Direct Streams" (deliberately non-standard API)
Workers IdentityTransformStream (Workers-specific)

Each "works in some scenarios but not in others, in some runtimes but not others […] creates friction for developers trying to write cross-runtime code, particularly those maintaining frameworks that must be able to run efficiently across many runtime environments."

Node streams vs Web streams

Workers prefer Web Streams. Node.js frameworks built against stream.Readable need an adapter to produce a ReadableStream. The straightforward-looking adapter chain:

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

runs data through two buffers — Node's stream internal buffer and the Web stream's internal buffer. For bytes-in-hand use cases, ReadableStream.from(chunks) skips both adapter layers. See concepts/stream-adapter-overhead.

Third-party corroboration from Vercel: "Native WebStream pipeThrough at 630 MB/s for 1KB chunks. Node.js pipeline() with the same passthrough transform: ~7,900 MB/s. That is a 12× gap, and the difference is almost entirely Promise and object allocation overhead." (Malte Ubl, We Ralph Wiggum'd WebStreams)

Proposed alternative: new-streams

systems/new-streams is Snell's 2026-02-27 proof-of-concept alternative built around AsyncIterable + pull-based + explicit backpressure policies. Benchmarks 2×-120× faster than Web streams across every tested runtime; not a ship-it proposal, explicitly a conversation-starter about what JS streaming "should" look like post-ES2018.

Integration with streaming SSR

Web Streams is the substrate under React 18 streaming SSR (renderToReadableStream) — see concepts/streaming-ssr. Pitfalls around buffering defaults at intermediate hops (compression middleware, proxies) apply here; the 2025-10 Cloudflare post adds the stream-adapter layer as another place collapse happens invisibly. The 2026-02 post argues that the per-request GC cost of streaming SSR through Web streams can reach 50 %+ of CPU time per request — making the current substrate a primary optimization target for server-side rendering frameworks.

Seen in

Last updated · 200 distilled / 1,178 read