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:

  1. A ReadableStreamDefaultReadRequest object is allocated with three callback slots
  2. The request is enqueued into the stream's internal queue
  3. A new Promise is allocated and returned
  4. 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 ReadableStreamDefaultReadRequest pattern 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 .then property, the Promise machinery will invoke it. Some WPT tests deliberately place .then on read results, so you must be careful about where {value, done} objects are created in hot paths.
  • Node.js pipeline() cannot replace WHATWG pipeTo. 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-patches Function.prototype.call and 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:

  1. read() fast path: When data is already buffered, return a resolved Promise directly without creating a ReadableStreamDefaultReadRequest object. This is spec-compliant because resolved promises still run callbacks in the microtask queue.
  2. pipeTo() batch reads: When data is buffered, batch multiple reads from the controller queue without per-chunk request objects, respecting backpressure by checking desiredSize after 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.