Date: January 2026
This is a prototype of an alternative streams API and implementation designed to address fundamental ergonomics and performance flaws in the Web Streams API model.
This is a work in progress and should not be considered a final design. The API and implementation are evolving as we explore design tradeoffs and gather feedback.
DO NOT USE THIS API IN PRODUCTION — it is not stable and may change significantly.
The WHATWG Streams Standard[^1] ("Web streams") provides a foundation for streaming data on the web platform, but suffers from significant usability issues stemming from its design predating async iteration and from attempting to serve too many use cases with a single complex abstraction. This document critiques Web Streams and presents design principles for a simpler, iterable-based alternative.
A complete reference implementation is available in this repository demonstrating these principles. See DESIGN.md for the API design and API.md for the complete API reference.
The new streams API follows these principles:
No custom Stream class with hidden state. Streams are AsyncIterable<Uint8Array[]> or Iterable<Uint8Array[]> - standard JavaScript iteration protocols. The for await...of syntax is the idiomatic way to consume a stream.
// Streams are standard async iterables
for await (const chunks of readable) {
for (const chunk of chunks) {
process(chunk);
}
}
Transforms only execute when the consumer pulls. No eager evaluation, no hidden buffering. Data flows on-demand from source through transforms to consumer.
// Nothing executes until iteration begins
const output = Stream.pull(source, compress, encrypt);
// Transforms execute as we iterate
for await (const chunks of output) { /* ... */ }
Strict by default, reject on overflow. All buffering has explicit limits with configurable overflow policies:
'strict' (default) - Writes reject when buffer full'block' - Writes wait until space available'drop-oldest' - Drop oldest buffered chunks to make room'drop-newest' - Drop incoming chunks when buffer fullconst { writer, readable } = Stream.push({
highWaterMark: 10,
backpressure: 'drop-oldest'
});
Pull-streams implement inherent backpressure by only producing data when the consumer requests it.
Push-streams enforce strictly following backpressure policies on writes by default.
Iterables yield Uint8Array[] (arrays of chunks) to amortize async overhead. This batching reduces promise creation per-chunk:
for await (const chunks of readable) { // chunks is Uint8Array[]
for (const chunk of chunks) { // chunk is Uint8Array
process(chunk);
}
}
Writers accept Uint8Array[] for batched/vectorized writes:
await writer.writev([chunk1, chunk2, chunk3]); // Write multiple chunks at once
No built-in tee() with hidden unbounded buffers. Instead, explicit multi-consumer patterns:
Stream.broadcast() - Push model with writer pushing to all consumersStream.share() - Pull model with shared source pulled on demandBoth require explicit buffer limits and backpressure policies:
// Share with explicit buffer management
const shared = Stream.share(source, {
highWaterMark: 100,
backpressure: 'strict'
});
const consumer1 = shared.pull();
const consumer2 = shared.pull(decompress);
For both models, a single cursor-based queue manages data flow to multiple consumers, ensuring predictable memory usage.
Complete parallel sync versions for CPU-bound workloads. No ambiguity about which path executes:
| Async | Sync |
|---|---|
Stream.pull() |
Stream.pullSync() |
Stream.pipeTo() |
Stream.pipeToSync() |
Stream.bytes() |
Stream.bytesSync() |
Stream.text() |
Stream.textSync() |
Stream.share() |
Stream.shareSync() |
The design allows for a synchronous fast path when all components are synchronous, eliminating unnecessary promise overhead.
Unlike Web Streams, desiredSize is always >= 0 or null (closed). The API enforces strict backpressure semantics - no negative values indicating "over capacity."
The API deals exclusively with bytes (Uint8Array). Strings are UTF-8 encoded automatically. No "value streams" - use async iterables directly for streaming arbitrary JS values.
Operations work on chunks (contiguous byte sequences) rather than individual bytes. No BYOB (bring your own buffer), no min/max read sizes, no partial fill handling.
| Approach | Overhead | Complexity |
|---|---|---|
| Byte-oriented | Per-byte processing, frequent allocations | High (BYOB requests, partial fills) |
| Chunk-oriented | Per-chunk processing, batch allocations | Low (simple iteration over chunks) |
The API does not automatically transfer or detach buffers. Use ArrayBuffer.transfer() explicitly when needed. This keeps the API simple and predictable while allowing developers to opt into transfer semantics when performance requires it.
Implementers may optimize away promise chains when operations complete synchronously. The API does not mandate promise creation in all cases, allowing for efficient synchronous fast paths.
A complete reference implementation is available in this repository demonstrating the API design principles above. The implementation is tested (194 tests) and benchmarked against Web Streams.
| Document | Description |
|---|---|
| DESIGN.md | Comprehensive API design document covering push streams, pull pipelines, transforms, consumers, multi-consumer patterns, and protocol extensibility |
| API.md | Complete API reference for the reference implementation |
| USAGE.md | Guide to using the API with code examples |
| REQUIREMENTS.md | Detailed list of testable assertions |
| MIGRATION.md | Guide for migrating from Web Streams API to this API |
| MIGRATION-NODEJS.md | Guide for migrating from Node.js streams to this API |
| DESIGN-TRADEOFFS.md | Discussion of design tradeoffs and rationale |
| COMPLETENESS-ANALYSIS.md | Analysis of feature completeness |
| TRANSFER-INTEGRATION.md | Design discussion: Transfer Protocol integration for ownership semantics |
| Resource | Description |
|---|---|
| src/ | TypeScript source code (types.ts, push.ts, from.ts, pull.ts, consumers.ts, broadcast.ts, share.ts) |
| samples/ | Sample files demonstrating API usage patterns |
| benchmarks/ | Benchmark suites comparing performance with Web Streams |
npm install new-streams
# Install dependencies
npm install
# Build
npm run build
# Run tests (194 tests)
npm test
# Run samples
npx tsx samples/01-basic-creation.ts
# Run benchmarks
npm run benchmark
# Run html samples/benchmark server
npm run html-samples
Note that these numbers are illustrative and will vary by environment and implementation status. They are provided here to demonstrate the performance characteristics of the new streams API compared to Web Streams and Node.js streams and should not be taken as definitive.
No assertion is made that these benchmarks represent real-world workloads. There is no intention to claim that the new streams API is universally faster than any specific Web streams or Node.js streams implementation in all scenarios. The benchmarks were run on a MacBook Pro (M1 Pro, 16GB RAM) using Node.js v24.x.
``` ────────────────────────────────────────────────────────────────────── Running: 01-throughput.ts ──────────────────────────────────────────────────────────────────────
Benchmark: Raw Throughput Measuring data flow speed through streams Comparing: New Streams vs Web Streams vs Node.js Streams New API uses batched iteration (Uint8Array[]) for amortized overhead (minimum 20 samples, 3 seconds per test)
Running: Large chunks... Running: Medium chunks... Running: Small chunks... Running: Tiny chunks... Running: Async iteration... Running: Generator source...
================================================================================================================================== BENCHMARK RESULTS (higher throughput = better) ================================================================================================================================== Scenario | New Stream | Web Stream | Node Stream | New vs Web | New vs Node ---------------------------------+--------------------+--------------------+--------------------+----------------------+--------------------- Large chunks (64KB x 500) | 4.87 GB/s | 6.03 GB/s | 5.83 GB/s | ~same | ~same Medium chunks (8KB x 2000) | 5.72 GB/s | 4.72 GB/s | 3.83 GB/s | ~same | ~same Small chunks (1KB x 5000) | 4.64 GB/s | 2.09 GB/s | 779.10 MB/s | 2.22x faster | 5.96x faster Tiny chunks (100B x 10000) | 1.18 GB/s | 275.86 MB/s | 124.15 MB/s | 4.28x faster | 9.50x faster Async iteration (8KB x 1000) | 310.78 GB/s | 19.05 GB/s | 12.39 GB/s | 16.31x faster | 25.08x faster Generator source (8KB x 1000) | 19.71 GB/s | 14.29 GB/s | 9.88 GB/s | ~same | 1.99x faster ==================================================================================================================================
New Stream vs Web Stream: 3 faster, 0 slower, 3 within noise New Stream vs Node Stream: 4 faster, 0 slower, 2 within noise Samples per benchmark: 100
────────────────────────────────────────────────────────────────────── Running: 02-push-streams.ts ──────────────────────────────────────────────────────────────────────
Benchmark: Push Stream Performance Measuring concurrent write/read patterns Comparing: New Streams vs Web Streams vs Node.js Streams (minimum 15-20 samples, 3 seconds per test)
Running: Concurrent push (medium chunks)... Running: Many small writes... Running: Batch writes... Running: Push + async iteration...
================================================================================================================================== BENCHMARK RESULTS (higher throughput = better) ================================================================================================================================== Scenario | New Stream | Web Stream | Node Stream | New vs Web | New vs Node ---------------------------------+--------------------+--------------------+--------------------+----------------------+--------------------- Concurrent push (4KB x 1000) | 174.92 MB/s | 181.45 MB/s | 166.52 MB/s | ~same | ~same Many small writes (64B x 10000) | 111.66 MB/s | 85.41 MB/s | 77.15 MB/s | 1.31x faster | ~same Batch writes (512B x 20 x 200) | 137.98 MB/s | 145.09 MB/s | 136.07 MB/s | ~same | ~same Push + async iter (2KB x 1000) | 162.86 MB/s | 166.54 MB/s | 162.72 MB/s | ~same | ~same ==================================================================================================================================
New Stream vs Web Stream: 1 faster, 0 slower, 3 within noise New Stream vs Node Stream: 0 faster, 0 slower, 4 within noise Samples per benchmark: 100
────────────────────────────────────────────────────────────────────── Running: 03-transforms.ts ──────────────────────────────────────────────────────────────────────
Benchmark: Transform Performance Measuring data transformation speed Comparing: New Streams vs Web Streams vs Node.js Streams (minimum 15-20 samples, 3 seconds per test)
Running: Identity transform... Running: XOR transform... Running: Expanding transform... Running: Chained transforms... Running: Async transform...
================================================================================================================================== BENCHMARK RESULTS (higher throughput = better) =======================================================================================================
$ claude mcp add iter-streams \
-- python -m otcore.mcp_server <graph>