Streams Are Everywhere, So Why Are They Still Awkward?

Streaming data is central to modern application development, and the WHATWG Streams Standard—commonly called "Web streams"—was created to give browsers and servers a shared way to handle it. The API shipped in browsers, was adopted by Cloudflare Workers, Node.js, Deno, and Bun, and now underpins core interfaces like fetch(). Designing it was a difficult task, and the people behind it made sound decisions given the constraints of the time.

But after years of implementing Web streams inside Node.js and Cloudflare Workers, debugging production failures in customer workloads and runtimes, and guiding developers through recurring pitfalls, it has become clear to me that the standard API suffers from severe usability and performance limitations. These aren’t defects that incremental fixes can address; they stem from architectural choices that no longer match the way JavaScript developers write software today. This article looks at what I believe are core flaws in Web streams and explains an alternative built on native JavaScript primitives that shows a better path forward.

In head-to-head benchmarks I ran, the alternative performs between 2x and 120x faster than Web streams across Cloudflare Workers, Node.js, Deno, Bun, and every major browser. The performance difference is not the result of aggressive micro-optimizations—it comes from fundamentally better design decisions that take advantage of modern JavaScript features. My goal is not to dismiss prior work, but to prompt a serious discussion about what the next iteration of streams might look like.

What Makes Web Streams Hard to Live With?

Web streams expose their functionality through a layered object model, which feels heavier and more error-prone than typical JavaScript APIs. A common pattern to read from a stream involves chaining ReadableStream, getReader(), read(), and the done flag from ReadableStreamDefaultReadResult. The steps required to do something simple—like draining a stream into a buffer—quickly become verbose:

BLOG-3183 Hero Image

Although the standard API corrects many historical inconsistencies across fetch bodies and Node.js streams, it introduces its own friction. In my experience, the object-heavy design obscures the underlying flow of data rather than clarifying it. Developers frequently get confused about when to use getReader() versus iterating with for await, and whether to call cancel() or let the stream be garbage-collected. These aren’t just beginner mistakes; they happen with experienced engineers too.

Configuring streams also requires making choices about queuing strategies and start/pull/cancel callbacks that are difficult to reason about, even for internal implementations like TransformStream or ReadableStream subclasses. The API feels modeled after older, callback-driven runtimes, which leads to code that is fragmented and hard to compose with modern syntax.

A Simpler Building Block: Async Iterators

A viable alternative is to build streams on top of JavaScript’s own async iteration protocol. Instead of forcing every backpressure, cancellation, and state transition into a bespoke object model, the API can simply expose async iterables. This leverages the language features that developers already know, supports for await...of natively, and composes well with other tools like ReadableStream.from() which already tests the limits of the Web streams model in reverse.

An async-iterable-based stream implementation can offer a small interface—with eager and lazy composition, error handling, and backpressure handled uniformly by the language runtime. Rather than duplicating features like teeing (tee()) with error-prone copies, the design utilizes the natural single-pass consumption model of async iterators.

When compared directly, the operations that require juggling pipeTo(), pipeThrough(), and manual pulling within ReadableStream reduce to simple loops or higher-order functions that mirror array methods, such as:

  • map and filter for transform pipelines
  • take/drop to limit or start from the nth element
  • concat or prepend to combine streams
  • Standard finally blocks to clean up resources automatically

Backpressure is implicit in the next() promise that governs pull-based delivery, and cancellation maps to calling return() on the iterator. This drastically reduces boilerplate and simplifies the mental model for both producers and consumers.

The Performance Gain Comes From Design, Not Tuning

Modern JavaScript engines have invested aggressively in optimizing the language core—generators, async functions, and iterators get deep compiler attention. As a result, a streams-like API built directly on those primitives executes well since the runtime performs its typical optimizations on code that looks like ordinary application code, not on opaque objects with hidden internal slots and queued internal state.

In my measurements across runtimes and browsers, the simpler interface outperforms Web streams for reading and piping workloads, at times dramatically. Numbers were consistently in the range stated above. The speed advantage is not because one set of primitives is newer, but because of how they are combined: fewer layers of abstraction, no extra buffering through queue managers, and reductions in the number of microtasks dedicated to state machine transitions. Rebuilding these APIs natively may benefit not just user code but also the runtimes on top of them—especially where streams are used for high-throughput proxy workloads or body transformation in requests/responses.

Perhaps it is time to move on from a decade-old model. The language itself has evolved to support patterns that are simpler to read, look no less safe, and are meaningfully faster in practice. That said, these journeys are never a one-way corridor: compatibility matters, existing standards need to serve real deployments, and a rushed redesign would prove just as harmful as ignoring the problem. Starting a conversation with measurements and a proof-of-concept is the first responsible step.

What the Streams Standard got wrong

The Streams Standard, finalized in 2016, set out to give the web platform a unified way to handle streaming data. It predates JavaScript's async iteration, which only arrived with ES2018 two years later. That timing shaped the API in ways that still ripple through every ReadableStream and TransformStream you use today.

The spec chose its own reader/writer acquisition model over what would become the idiomatic JavaScript pattern for consuming async sequences. The result is an API that layers ceremony on top of ceremony for what should be simple operations.

BLOG-3183 Image 1

The cost of ceremony

Reading a stream to completion with Web streams requires a lock acquisition dance that looks like this:

// First, we acquire a reader that gives an exclusive lock
// on the stream...
const reader = stream.getReader();
const chunks = [];
try {
  // Second, we repeatedly call read and await on the returned
  // promise to either yield a chunk of data or indicate we're
  // done.
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    chunks.push(value);
  }
} finally {
  // Finally, we release the lock on the stream
  reader.releaseLock();
}

None of that complexity is inherent to streaming. The reader acquisition, lock management, and the { value, done } protocol are design artifacts, not necessities. Async iteration now exists precisely to handle sequences arriving over time, and since it was retrofitted onto Web streams, you can write the simpler version:

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

That removes boilerplate but doesn't eliminate the underlying complexity. BYOB reads aren't accessible through iteration. The readers, locks, and controllers are still there, just hidden. When something goes wrong, you're back to untangling why a stream is locked or why releaseLock() didn't behave as expected.

The locking trap

The locking model prevents multiple consumers from interleaving reads. Calling getReader() locks the stream, blocking any other reader, pipe, or cancel operation. That sounds reasonable until you forget to release the lock:

async function peekFirstChunk(stream) {
  const reader = stream.getReader();
  const { value } = await reader.read();
  // Oops — forgot to call reader.releaseLock()
  // And the reader is no longer available when we return
  return value;
}

const first = await peekFirstChunk(stream);
// TypeError: Cannot obtain lock — stream is permanently locked
for await (const chunk of stream) { /* never runs */ }

A forgotten releaseLock() permanently bricks the stream. The locked property tells you a stream is locked, but not why, by whom, or whether the lock is still usable. Piping internally acquires locks, making streams unusable during pipe operations in opaque ways. The semantics around releasing locks with pending reads were ambiguous for years — the spec was recently clarified to cancel pending reads, but implementations varied. Locking itself serves a legitimate purpose: orderly data consumption. The problem is the manual implementation. Automatic lock management through async iterables made life easier for users, but implementers still deal with substantial internal bookkeeping: every operation must check lock state, track readers, and handle the matrix of cancellation and error edge cases.

BYOB's complexity without payoff

BYOB (bring your own buffer) reads were meant as a zero-copy optimization for high-throughput scenarios. Instead of the stream allocating new buffers per chunk, you provide your own buffer and the stream fills it. In practice, BYOB sees minimal adoption because it's complex for everyone involved.

The API requires a separate reader type (ReadableStreamBYOBReader), specialized classes like ReadableStreamBYOBRequest, and careful management of ArrayBuffer detachment. When you hand a buffer to a BYOB read, it becomes transferred to the stream; you get back a different view over different memory. This is error-prone:

const reader = stream.getReader({ mode: 'byob' });
const buffer = new ArrayBuffer(1024);
let view = new Uint8Array(buffer);

const result = await reader.read(view);
// 'view' should now be detached and unusable
// (it isn't always in every impl)
// result.value is a NEW view, possibly over different memory
view = result.value; // Must reassign

BYOB also doesn't work with async iteration or TransformStreams, forcing developers who want zero-copy back into the manual reader loop. For implementers, BYOB adds tracking of pending requests, partial fills, and buffer detachment coordination. The web platform tests include dedicated files just for BYOB edge cases: detached buffers, bad views, and response-after-enqueue ordering.

Most userland ReadableStream implementations skip proper dual default/BYOB support. A "correct" implementation is huge and error-prone — not something typical developers want to handle:

new ReadableStream({
    type: 'bytes',
    
    async pull(controller: ReadableByteStreamController) {      
      if (offset >= totalBytes) {
        controller.close();
        return;
      }
      
      // Check for BYOB request FIRST
      const byobRequest = controller.byobRequest;
      
      if (byobRequest) {
        // === BYOB PATH ===
        // Consumer provided a buffer - we MUST fill it (or part of it)
        const view = byobRequest.view!;
        const bytesAvailable = totalBytes - offset;
        const bytesToWrite = Math.min(view.byteLength, bytesAvailable);
        
        // Create a view into the consumer's buffer and fill it
        // not critical but safer when bytesToWrite != view.byteLength
        const dest = new Uint8Array(
          view.buffer,
          view.byteOffset,
          bytesToWrite
        );
        
        // Fill with sequential bytes (our "data source")
        // Can be any thing here that writes into the view
        for (let i = 0; i < bytesToWrite; i++) {
          dest[i] = (offset + i) & 0xFF;
        }
        
        offset += bytesToWrite;
        
        // Signal how many bytes we wrote
        byobRequest.respond(bytesToWrite);
        
      } else {
        // === DEFAULT READER PATH ===
        // No BYOB request - allocate and enqueue a chunk
        const bytesAvailable = totalBytes - offset;
        const chunkSize = Math.min(1024, bytesAvailable);
        
        const chunk = new Uint8Array(chunkSize);
        for (let i = 0; i < chunkSize; i++) {
          chunk[i] = (offset + i) & 0xFF;
        }
        
        offset += chunkSize;
        controller.enqueue(chunk);
      }
    },
    
    cancel(reason) {
      console.log('Stream canceled:', reason);
    }
  });

Host runtimes can provide optimized implementations for their own byte-oriented streams, like a fetch response body, but those still need to handle both default and BYOB read patterns, adding significant complexity.

Backpressure is advisory, not enforced

Backpressure is a first-class concept in Web streams theory. The primary signal is desiredSize on the controller — positive means wants data, zero means at capacity, negative means over capacity. Producers are expected to check it and stop enqueueing when it drops. But nothing enforces that; controller.enqueue() always succeeds, even with deeply negative desiredSize.

new ReadableStream({
  start(controller) {
    // Nothing stops you from doing this
    while (true) {
      controller.enqueue(generateData()); // desiredSize: -999999
    }
  }
});

Streams can and do ignore backpressure. Some spec-defined features explicitly break it. tee(), for instance, creates two branches with an unbounded internal buffer; a fast consumer can cause unbounded memory growth while a slow one catches up, with no way to configure the limit. The highWaterMark options and size calculations exist for tuning, but they're just as ignorable.

The same issue applies to WritableStream, which has its own highWaterMark and desiredSize, with a writer.ready promise that producers are supposed to heed:

const writable = getWritableStreamSomehow();
const writer = writable.getWriter();

// Producers are supposed to wait for the writer.ready
// It is a promise that, when resolves, indicates that
// the writables internal backpressure is cleared and
// it is ok to write more data
await writer.ready;
await writer.write(...);

For implementers, all this machinery — tracking queue sizes, computing desiredSize, invoking pull() on schedule — adds complexity without providing actual guarantees. The signals are advisory; the work doesn't prevent the problems backpressure is meant to solve.

The promise overhead tax

The spec mandates promise creation at numerous points, often in hot paths and invisible to users. Every read() call creates internal promises for queue management, pull() coordination, and backpressure signaling. This overhead compounds in pipelines — each TransformStream adds another layer of promise machinery between source and sink, with no synchronous fast paths defined.

For implementers, the mandated promise resolution ordering constrains optimization. Batched operations or skipped async boundaries risk subtle spec violations. Vercel's research into Node.js Web streams performance found exactly this problem, as Malte Ubl wrote:

"Or consider pipeTo(). Each chunk passes through a full Promise chain: read, write, check backpressure, repeat. An {value, done} result object is allocated per read. Error propagation creates additional Promise branches.

None of this is wrong. These guarantees matter in the browser where streams cross security boundaries, where cancellation semantics need to be airtight, where you do not control both ends of a pipe. But on the server, when you are piping React Server Components through three transforms at 1KB chunks, the cost adds up.

We benchmarked 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 12x gap, and the difference is almost entirely Promise and object allocation overhead."

Vercel's proposed improvements to eliminate promises in certain paths could yield up to 10x faster performance. Similar optimizations to a Cloudflare Workers internal pipeline reduced promise creation by up to 200x in certain scenarios.

Real-world failures

Unconsumed response bodies

When fetch() returns a response, its body is a ReadableStream. If you only check the status and skip consuming or canceling the body, you risk resource leaks:

async function checkEndpoint(url) {
  const response = await fetch(url);
  return response.ok; // Body is never consumed or cancelled
}

// In a loop, this can exhaust connection pools
for (const url of urls) {
  await checkEndpoint(url);
}

This pattern has caused connection pool exhaustion in Node.js applications using undici, the built-in fetch() implementation. The stream holds a reference to the connection; without explicit consumption or cancellation, it lingers until garbage collection — which may not arrive in time under load. APIs like Request.clone() and Response.clone() compound the problem by implicitly tee()-ing the body stream, multiplying the branches that need independent consumption.

"Cloning streams in Node.js's fetch() implementation is harder than it looks. When you clone a request or response body, you're calling tee() - which splits a single stream into two branches that both need to be consumed. If one consumer reads faster than the other, data buffers unbounded in memory waiting for the slow branch. If you don't properly consume both branches, the underlying connection leaks..." - Matteo Collina, Ph.D., Node.js Technical Steering Committee Chair

The tee() memory cliff

tee() splits a stream into two branches, buffering data for the slower branch. The spec doesn't mandate buffer limits if implementations follow its described approach, resulting in an inherent memory management problem.

const [forHash, forStorage] = response.body.tee();

// Hash computation is fast
const hash = await computeHash(forHash);

// Storage write is slow — meanwhile, the entire stream
// may be buffered in memory waiting for this branch
await writeToStorage(forStorage);

Implementations have improvised their own strategies. Firefox initially used a linked-list approach with O(n) memory growth proportional to the consumption rate difference. Cloudflare Workers implemented a shared buffer model where the slowest consumer signals backpressure:

BLOG-3183 Image 2

Transform backpressure gaps

TransformStream calls its transform() function on write, not read, processing data eagerly whether or not a consumer is ready. If the transform is synchronous and immediately enqueues output, it never signals backpressure even with a slow downstream consumer. This matters little in browsers with limited pipelines but devastatingly affects server-side runtimes serving thousands of concurrent requests.

const fastTransform = new TransformStream({
  transform(chunk, controller) {
    // Synchronously enqueue — this never applies backpressure
    // Even if the readable side's buffer is full, this succeeds
    controller.enqueue(processChunk(chunk));
  }
});

// Pipe a fast source through the transform to a slow sink
fastSource
  .pipeThrough(fastTransform)
  .pipeTo(slowSink);  // Buffer grows without bound

Correct implementations poll for desiredSize because TransformStreamDefaultController lacks a ready promise like writers have:

const fastTransform = new TransformStream({
  async transform(chunk, controller) {
    if (controller.desiredSize <= 0) {
      // Wait on the backpressure to clear somehow
    }

    controller.enqueue(processChunk(chunk));
  }
});

Pipelines make this worse. Chaining multiple transforms — parse, transform, serialize — creates independent internal buffers at each stage, so data cascades push-style through accumulated intermediate buffering. Three transforms mean six internal buffers filling simultaneously. Users are expected to pass highWaterMark values everywhere but often forget.

source
  .pipeThrough(parse)      // buffers filling...
  .pipeThrough(transform)  // more buffers filling...
  .pipeThrough(serialize)  // even more buffers...
  .pipeTo(destination);    // consumer hasn't started yet

Native path optimizations in Deno, Bun, and Cloudflare Workers collapse identity transforms and bypass JavaScript entirely, but they can't escape TransformStream's inherently push-oriented model:

BLOG-3183 Image 3

GC thrashing under SSR

Streaming server-side rendering is a particularly brutal case. A typical SSR stream might render thousands of small HTML fragments, each creating promises for read() calls, backpressure coordination, intermediate buffers, and { value, done } result objects that become garbage almost immediately:

// Each component enqueues a small chunk
function renderComponent(controller) {
  controller.enqueue(encoder.encode(`<div>${content}</div>`));
}

// Hundreds of components = hundreds of enqueue calls
// Each one triggers promise machinery internally
for (const component of components) {
  renderComponent(controller);  // Promises created, objects allocated
}

Under load, this GC pressure devastates throughput. The engine spends significant time collecting short-lived objects, and GC pauses make latency unpredictable. SSR workloads can spend more than 50% of total CPU time on garbage collection per request. The irony is that streaming SSR is meant to improve performance by sending content incrementally, but the streams machinery can negate those gains — buffering the entire response often turns out faster.

The implementation treadmill

Every major runtime has resorted to non-standard internal optimizations to achieve usable performance. Node.js, Deno, Bun, and Cloudflare Workers each developed their own workarounds. Finding these optimization opportunities requires deep spec expertise to identify observable versus elidable behavior; whether a given shortcut is truly spec-compliant is often unclear.

This leads to fragmentation. Bun's "Direct Streams" deliberately diverges from spec observables. Cloudflare Workers' IdentityTransformStream provides a Workers-specific fast path implementing non-standard behavior. Code that performs well on one runtime may behave differently on another despite using "standard" APIs. Framework maintainers striving for cross-runtime efficiency face constant friction from these subtle behavioral differences.

Most runtime implementers stop improving streams implementations once conformance tests pass. When you need to bypass spec semantics just to reach reasonable performance, the spec itself is suspect. A well-designed streaming API should be efficient by default, not require each runtime to engineer its own escape hatches.

The compliance burden

The Web Platform Tests for streams span over 70 test files. The telling part is what needs testing:

  • Prototype pollution defense tests patch Object.prototype.then to verify pipeTo() and tee() don't leak internal values — a security property that exists only because promise-heavy internals create an attack surface.
  • WebAssembly memory rejection requires BYOB reads to reject arrays backed by WASM memory, an edge case arising from the spec's buffer detachment model.
  • Crash regressions verify that calling byobRequest.respond() after enqueue() doesn't corrupt memory, covering likely misuse of a complex API.

These aren't hypothetical. They encode the full matrix of interactions between readers, writers, controllers, queues, strategies, and promise machinery that have caused real-world bugs. A simpler API would mean fewer concepts, fewer interactions, and fewer edge cases — resulting in implementations that behave more consistently.

A foundation that needs rebuilding

The problems with Web streams aren't spec bugs; they emerge from using the API exactly as designed. They stem from fundamental design choices made before async iteration existed, choices that now impose complexity on users and implementers alike. These aren't problems that incremental improvements can fix — they require different foundations.

Rethinking the JavaScript streams API

After implementing the Web streams specification multiple times across different runtimes, the pain points become hard to ignore. The result is a proof of concept for an alternative streaming API — not a finished standard or production library, but a starting point for discussion about whether the problems with Web streams are inherent to streaming itself, or consequences of specific design choices that could be made differently.

Streams as iterables

At its core, a stream is a sequence of data that arrives over time. Unix pipes express this most purely: data flows left to right, each stage reads input and writes output, and backpressure is implicit — no pipe reader to acquire, no controller lock to manage.

JavaScript already has a native primitive for "a sequence of things that arrive over time": the async iterable, consumed with for await...of and stopped by stopping iteration. The complexity of Web streams — readers, writers, controllers, locks, queuing strategies — obscures that fundamental simplicity.

The proof-of-concept API is built around a different set of principles:

  • Streams are iterables. A readable stream is just an AsyncIterable<Uint8Array[]>. No custom ReadableStream class with hidden internal state, no readers to acquire, no locks to manage.
  • Pull-through transforms. Transforms don't execute until the consumer pulls. Data flows on-demand from source through transforms; stop iterating and processing stops.
  • Explicit backpressure. Strict by default — writes reject when a buffer is full rather than silently accumulating. Alternative policies (block, drop-oldest, drop-newest) must be chosen explicitly.
  • Batched chunks. Streams yield Uint8Array[], arrays of chunks, to amortize async overhead across multiple chunks and reduce promise creation and microtask latency.
  • Bytes only. The API deals exclusively with Uint8Array; strings are UTF-8 encoded automatically. Chunks are treated as opaque — no partial consumption, no BYOB patterns. For arbitrary JavaScript values, use async iterables directly.
  • Synchronous fast paths. Sync sources are common, and the API should not force the cost of async scheduling when data is already available. Sync paths are always optional and always explicit.

The API in practice

Creating a producer/consumer pair with Web streams requires a TransformStream, manual TextEncoder/TextDecoder, and careful lock management. The new API's readable is just an async iterable that can be passed to any function expecting one, and the writer interface is minimal: write(), writev() for batched writes, end(), and abort().

The writer is not a concrete class — any object implementing those methods works, making it easy to adapt existing APIs without subclassing. There's no complex UnderlyingSink protocol with start(), write(), close(), and abort() callbacks coordinating through a controller whose lifecycle is independent of the WritableStream it's bound to.

Pull-through transforms are central to the design. Stream.pull() creates a lazy pipeline where transforms don't run until output is iterated. This differs fundamentally from pipeThrough(), which starts pumping data as soon as the pipe is set up. Transforms are just functions or simple objects — stateless ones take chunks and return transformed chunks; stateful ones maintain state across calls via member functions; cleanup on abort is handled by an abort handler. No Transformer protocol with start(), transform(), and flush() methods coordinating with a hidden state machine.

Backpressure policies

When a bounded buffer fills, only four responses are possible: reject the write, block until space is available, discard old buffered data, or discard incoming data. Web streams always chooses to wait by default. The new API requires an explicit choice:

  • strict (default): rejects writes when the buffer is full and too many writes are pending, catching fire-and-forget patterns where producers ignore backpressure.
  • block: writes wait until buffer space is available; use when you trust the producer to await properly.
  • drop-oldest: evicts the oldest buffered data; useful for live feeds where stale data loses value.
  • drop-newest: discards incoming data when full; useful when you want to process what you have without being overwhelmed.

For multi-consumer scenarios, instead of tee() with its hidden unbounded buffer, there are explicit primitives. Stream.share() is pull-based, with buffer limits and backpressure policy configured upfront. Stream.broadcast() handles push-based multi-consumer cases. Both force you to think about what happens when consumers run at different speeds.

Synchronous pipelines

Not all streaming workloads involve I/O. With in-memory sources and pure-function transforms, async machinery adds overhead without benefit. The new API provides parallel sync versions: Stream.pullSync(), Stream.bytesSync(), Stream.textSync(), and more. A complete sync pipeline — compression, transformation, consumption — executes in a single call stack with no promises, no microtask queue scheduling, and no GC pressure from short-lived async machinery.

Web streams has no synchronous path. Even when every component has data ready, you pay for promise creation and microtask scheduling on every operation. Promises are valuable when waiting is necessary, but they aren't always necessary.

Interoperability and real-world impact

The async iterable approach provides a natural bridge. Since ReadableStream can be an async iterable, passing it directly into the new API works when it yields bytes. Adapting back to ReadableStream requires slightly more work since the new API yields batches of chunks, but the adaptation layer is straightforward.

The design addresses the real-world failure modes of Web streams:

  • Unconsumed bodies: pull semantics mean nothing happens until iteration starts, so no hidden resource retention when streams aren't consumed.
  • The tee() memory cliff: Stream.share() requires explicit highWaterMark and backpressure policy configuration, eliminating silent unbounded growth.
  • Transform backpressure gaps: pull-through semantics mean data doesn't cascade through intermediate buffers; it flows only on demand.
  • GC thrashing in SSR: batched chunks amortize async overhead, and sync pipelines eliminate promise allocation entirely for CPU-bound work.

Benchmarks and trade-offs

Benchmarks from the reference implementation compared against Web streams (Node.js v24.x, Apple M1 Pro, averaged over 10 runs) show consistent gains, with chained transforms particularly strong: pull semantics eliminate the intermediate buffering that plagues Web streams pipelines, which eagerly fill internal buffers at each TransformStream stage.

Browser benchmarks (Chrome/Blink, averaged over 3 runs) show gains as well. These compare pure TypeScript/JavaScript implementations of the new API against native implementations of Web streams, with no performance optimization work on the reference implementation — gains come from design choices alone. It's also fair to note that Node.js hasn't yet invested heavily in optimizing its Web streams hot paths, but results in Deno and Bun show similar improvements.

"We've done a lot to improve performance and consistency in Node streams, but there's something uniquely powerful about starting from scratch. New streams' approach embraces modern runtime realities without legacy baggage, and that opens the door to a simpler, performant and more coherent streams model." — Robert Nagy, Node.js TSC member and Node.js streams contributor

Whether this exact API is the right answer matters less than whether it shifts the conversation about what a streaming primitive should actually provide. The simplicity of iteration, the discipline of explicit backpressure, and the freedom to avoid async overhead when it isn't needed suggest the problems with Web streams aren't inherent to streaming — they're design choices that could be made differently.

Where the Discussion Goes From Here

This proposal is meant to open a conversation, not to crown a winner. The open questions are practical ones: which use cases don’t fit the model, what a migration path for existing code would look like, and where the design still falls short. The intent is to collect input from developers who have hit the rough edges of the current Web Streams implementation and have a sense of what a fresh design should prioritize.

Get Hands-On With the Reference Implementation

A working reference implementation is available today at https://github.com/jasnell/new-streams. For full documentation and usage guidance, see the API.md file, and the samples directory contains ready-to-run examples illustrating common patterns.

Issues, discussions, and pull requests are all welcome. If your own experience with Web Streams uncovered gaps this design doesn’t address, that’s exactly the feedback the project needs. The point here is not to push adoption of a new shiny object, but to step back from the status quo and reconsider streaming from first principles.

Web Streams was an ambitious undertaking that brought streaming to the platform when no alternative existed. The designers made reasonable calls given the state of JavaScript in 2014 — before async iteration was standardized and before real-world usage exposed many of the edge cases we now know about.

Enough has changed since then. The language has evolved, and a streaming API designed today could be leaner, more idiomatic, and more deliberate about the behaviors that matter most — backpressure and multi-consumer semantics above all. A better stream API is within reach. Let’s work out what it looks like.