Why WebStreams were the bottleneck in server rendering
When we profiled Next.js server rendering earlier this year, the flamegraphs consistently pointed to WebStreams themselves, not the application code running inside them. Theo Browne's server rendering benchmarks confirmed that a significant portion of compute time goes into framework overhead — and a lot of that overhead lives in streams.
Node.js has two streaming APIs. The older stream.Readable, stream.Writable, and stream.Transform have been optimized for over a decade: data flows through C++ internals, backpressure is a boolean, and piping is a single function call. The WHATWG Streams API (ReadableStream, WritableStream, TransformStream) is the web standard that powers fetch(), CompressionStream, and increasingly server-side rendering. It is the right convergence target, but on the server it is far slower than it should be.
Consider what happens when you call reader.read() on a native WebStream in Node.js, even when data is already buffered:
- A
ReadableStreamDefaultReadRequestobject is allocated with three callback slots - The request is enqueued into the stream's internal queue
- A new Promise is allocated and returned
- Resolution goes through the microtask queue
That is four allocations and a microtask hop to return data that was already there. pipeTo() is similar: each chunk passes through a full Promise chain for read, write, and backpressure checks, with an {value, done} object allocated per read.
These guarantees matter in the browser where streams cross security boundaries and you do not control both ends of a pipe. But when piping React Server Components through three transforms at 1KB chunks, the cost is substantial. In our benchmarks, native WebStream pipeThrough ran at 630 MB/s for 1KB chunks, while Node.js pipeline() with the same passthrough transform hit ~7,900 MB/s — a 12x gap driven almost entirely by Promise and object allocation.
A fast-path library for WHATWG streams
We built fast-webstreams, a library that implements the WHATWG ReadableStream, WritableStream, and TransformStream APIs on top of Node.js streams internally. The public API, error propagation, and spec compliance are preserved; the overhead is removed for common server-side patterns.
Chaining pipes: zero Promises per chunk
The biggest win comes from lazy pipe linking. When you chain pipeThrough between fast streams, the library does not start piping immediately. It records the upstream links:
source → transform1 → transform2 → ...
When pipeTo() is called at the end, the library walks upstream, collects the underlying Node.js stream objects, and makes a single pipeline() call. One function call, zero Promises per chunk, with data flowing through Node's optimized C++ path.
const source = new ReadableStream({
pull(controller) {
controller.enqueue(generateChunk());
}
});
const transform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(process(chunk));
}
});
const sink = new WritableStream({
write(chunk) { consume(chunk); }
});
// Internally: single pipeline() call, zero promises per chunk
await source.pipeThrough(transform).pipeTo(sink);
That yields ~6,200 MB/s — roughly 10x faster than native WebStreams and close to raw Node.js pipeline performance. If any stream in the chain is native (like CompressionStream), the library falls back to standard pipeThrough or pipeTo implementations.
Chunk-by-chunk reads: synchronous resolution
For individual reader.read() calls, the library first tries nodeReadable.read() synchronously. When data is available, it returns Promise.resolve({value, done}) — no event loop round-trip, no request object allocation. A pending Promise is only created when the buffer is empty.
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// When data is buffered, the await resolves immediately
// via Promise.resolve() — no microtask queue hop
processChunk(value);
}
This delivers ~12,400 MB/s, or 3.7x faster than native.
The React Flight pattern
The pattern that matters most for Next.js is React Server Components, which create a ReadableStream with type: 'bytes', capture the controller in start(), and enqueue chunks externally as rendering produces them.
let ctrl;
const stream = new ReadableStream({
type: 'bytes',
start(c) { ctrl = c; }
});
// As React renders each component:
ctrl.enqueue(new Uint8Array(payload1));
ctrl.enqueue(new Uint8Array(payload2));
ctrl.close();
Native WebStreams process this at ~110 MB/s; fast-webstreams runs at ~1,600 MB/s, a 14.6x speedup for the exact pattern used in production server rendering. The gain comes from LiteReadable, a minimal array-based buffer we created to replace Node.js's Readable for byte streams. It uses direct callback dispatch instead of EventEmitter, supports pull-based demand and BYOB readers, and shaves about 5 microseconds off each construction — significant when React Flight creates hundreds of byte streams per request.
Fetch response bodies
Most server-side streams do not start with new ReadableStream(). They come from fetch(), where the response body is a native byte stream owned by Node.js's HTTP layer. A common server rendering pattern is to fetch data, pipe it through one or more transforms, and forward the result to the client.
const upstream = await fetch('<https://api.example.com/data>');
// Pipe through transforms and forward as a new Response
const transformed = upstream.body
.pipeThrough(new TransformStream({ transform(chunk, ctrl) { /* ... */ ctrl.enqueue(chunk); } }))
.pipeThrough(new TransformStream({ transform(chunk, ctrl) { /* ... */ ctrl.enqueue(chunk); } }))
.pipeThrough(new TransformStream({ transform(chunk, ctrl) { /* ... */ ctrl.enqueue(chunk); } }));
return new Response(transformed);
With native WebStreams, each hop in such a chain pays the full Promise-per-chunk cost — three transforms means roughly 6-9 Promises per chunk, capping throughput at ~260 MB/s. The library's patchGlobalWebStreams() addresses this: Response.prototype.body returns a lightweight fast shell wrapping the native byte stream. pipeThrough() merely records the link. When pipeTo() or getReader() is called, the library resolves the full chain with one bridge from the native reader into Node.js pipeline(), then serves reads synchronously from buffered output.
The cost model: one Promise at the native boundary to pull data in, zero Promises through transforms, sync reads at the output. This yields ~830 MB/s, or 3.2x faster than native for three-transform fetch chains, and 2.0x faster for simple response forwarding without transforms.
Benchmark results
All numbers are throughput in MB/s at 1KB chunks on Node.js v22. Higher is better.
Core operations
Operation | Node.js streams | fast | native | fast vs native |
read loop | 26,400 | 12,400 | 3,300 | 3.7x |
write loop | 26,500 | 5,500 | 2,300 | 2.4x |
pipeThrough | 7,900 | 6,200 | 630 | 9.8x |
pipeTo | 14,000 | 2,500 | 1,400 | 1.8x |
for-await-of | — | 4,100 | 3,000 | 1.4x |
Transform chains
Promise overhead compounds with chain depth:
Depth | fast | native | fast vs native |
3 transforms | 2,900 | 300 | 9.7x |
8 transforms | 1,000 | 115 | 8.7x |
Byte streams
Pattern | fast | native | fast vs native |
start + enqueue (React Flight) | 1,600 | 110 | 14.6x |
byte read loop | 1,400 | 1,400 | 1.0x |
byte tee | 1,200 | 750 | 1.6x |
Response body patterns
Pattern | fast | native | fast vs native |
Response.text() | 900 | 910 | 1.0x |
Response forwarding | 850 | 430 | 2.0x |
fetch → 3 transforms | 830 | 260 | 3.2x |
Stream construction
Creation is also faster, which matters for short-lived streams:
Type | fast | native | fast vs native |
ReadableStream | 2,100 | 980 | 2.1x |
WritableStream | 1,300 | 440 | 3.0x |
TransformStream | 470 | 220 | 2.1x |
Spec compliance and the hard lessons
fast-webstreams passes 1,100 of 1,116 Web Platform Tests; Node.js's native implementation passes 1,099. The remaining 16 failures are either shared with native (like the unimplemented type: 'owning' transfer mode) or architectural differences that do not affect real applications.
The development process taught us several things the spec makes clear:
- The spec's design is intentional. Most shortcut attempts broke a Web Platform Test, and the test was usually right. The
ReadableStreamDefaultReadRequestpattern and Promise-per-read design exist because cancellation during reads, error identity through locked streams, and thenable interception are real edge cases. Promise.resolve(obj)always checks for thenables. If the resolved object has a.thenproperty, the Promise machinery will invoke it. Some WPT tests deliberately place.thenon read results, so you must be careful about where{value, done}objects are created in hot paths.- Node.js
pipeline()cannot replace WHATWGpipeTo. Using it for all piping caused 72 WPT failures due to fundamental differences in error propagation, stream locking, and cancellation semantics. It is only safe when we control the entire chain. - Use
Reflect.apply, not.call(). The WPT suite monkey-patchesFunction.prototype.calland verifies implementations do not use it to invoke user callbacks.
Deploying in production and pushing upstream
The library can patch the global ReadableStream, WritableStream, and TransformStream constructors:
import { patchGlobalWebStreams } from 'fast-webstreams';
patchGlobalWebStreams();
// globalThis.ReadableStream is now the fast implementation
// fetch() response bodies are automatically wrapped
// All downstream pipeThrough/pipeTo use fast paths
The patch also intercepts Response.prototype.body so fetch() → pipeThrough() → pipeTo() chains hit the pipeline fast path automatically. At Vercel, rollout is planned incrementally, starting with the highest-impact patterns: React Server Component streaming, response body forwarding, and multi-transform chains.
A userland library is not the long-term answer. Work is already happening inside Node.js. After a conversation on X, Matteo Collina submitted nodejs/node#61807, "stream: add fast paths for webstreams read and pipeTo," applying two ideas from this project to native WebStreams:
read()fast path: When data is already buffered, return a resolved Promise directly without creating aReadableStreamDefaultReadRequestobject. This is spec-compliant because resolved promises still run callbacks in the microtask queue.pipeTo()batch reads: When data is buffered, batch multiple reads from the controller queue without per-chunk request objects, respecting backpressure by checkingdesiredSizeafter each write.
The PR shows ~17-20% faster buffered reads and ~11% faster pipeTo, benefiting every Node.js user with no library installation or patching required. James Snell's Node.js performance issue #134 outlines additional opportunities: C++-level piping for internally-sourced streams, lazy buffering, and eliminating double-buffering in WritableStream adapters.
The goal is not for fast-webstreams to exist indefinitely. The goal is for WebStreams to be fast enough that it does not need to exist at all.
Availability and next steps
The optimization work is published on npm as experimental-fast-webstreams. The "experimental" label is deliberate: the implementation is correct to the best of the team's knowledge, but the project remains under active development.
The library targets developers building server-side JavaScript frameworks or runtimes that are hitting WebStreams performance ceilings. Contributions and feedback are welcome, particularly from those working directly with Node.js internals.
For those interested in pushing these improvements upstream, Matteo's pull request against Node.js provides an entry point for deeper collaboration on WebStreams optimization.



