Node.js 21 🟢 Web Streams API (ReadableStream, WritableStream, TransformStream)
Node.js has had its own stream implementation since the beginning. It is callback-based, event-driven, and deeply embedded in the ecosystem. The Web Streams API is different. It is a WHATWG standard, promise-based, and designed for interoperability across JavaScript environments—browsers, Deno, Bun, Cloudflare Workers, and Node.js. Node.js added an implementation in version 16, marked it experimental, and stabilized it in version 21. This chapter covers the three core classes: ReadableStream, WritableStream, and TransformStream, along with the readers, writers, and piping methods that connect them.
The Web Streams API is not a replacement for Node.js streams. It is a parallel implementation with different ergonomics. Understanding when to use each, and how to convert between them, is part of working with modern Node.js.
Key point: The Web Streams API is promise-based. Reading from a stream returns a promise. Writing to a stream returns a promise. Piping between streams returns a promise. This makes it composable with async/await in a way that Node.js streams, with their callback-driven _transform methods and event emitters, are not. The pipeThrough and pipeTo methods are the primary piping mechanism, and they handle backpressure automatically.
Why Web Streams exist
The interoperability problem. Browsers have fetch, and fetch returns a ReadableStream in the response body. The Response and Request objects consume and produce web streams. Node.js streams are not compatible with these APIs. When you write code that runs in both Node.js and the browser—edge functions, isomorphic libraries, shared utilities—a single stream abstraction is necessary. The Web Streams API is that abstraction .
The promise problem. Node.js streams are event emitters. Consuming a stream means listening for 'data', 'end', and 'error' events, or piping to a destination. The Web Streams API uses promises. reader.read() returns a promise that resolves to { value, done }. writer.write() returns a promise that resolves when the chunk is accepted. pipeTo() returns a promise that resolves when the pipeline is complete. This fits naturally into async/await code and error handling with try/catch .
The backpressure problem. Both APIs handle backpressure, but they expose it differently. Node.js streams use the highWaterMark and the return value of write() to signal whether the consumer is ready for more data. Web Streams use the same highWaterMark concept internally, but the backpressure is managed by the pipe operations. When you pipeTo a ReadableStream to a WritableStream, the pipe waits for the destination to be ready before reading the next chunk. You do not manage this manually .
The transferable problem. Web Streams can be transferred between workers using postMessage. A ReadableStream or WritableStream can be passed through a MessagePort, allowing stream processing to happen in a different thread. Node.js streams cannot be transferred this way .
The adoption problem. The Web Streams API is not universally better. It is newer, less optimized in V8, and has a smaller ecosystem of utilities. Node.js streams have decades of tooling, more mature implementations, and better performance for some workloads. The choice depends on the context. If you need browser compatibility, web streams. If you need performance and ecosystem, Node.js streams. If you need both, the toWeb and fromWeb methods bridge them .
a. ReadableStream: consuming data
A ReadableStream represents a source of data. You create it with an underlying source object that defines how data is produced.
import { ReadableStream } from 'node:stream/web';
const stream = new ReadableStream({
start(controller) {
controller.enqueue('a');
controller.enqueue('b');
controller.close();
}
});
The start method is called when the stream is created. The controller provides methods to enqueue chunks, close the stream, or error it. The chunks are queued internally and consumed by a reader.
The simplest way to consume a ReadableStream is async iteration:
for await (const chunk of stream) {
console.log(chunk);
}
// Prints: 'a', 'b'
The for await syntax works because ReadableStream implements the async iterator protocol. This is the most ergonomic consumption pattern. The stream is locked during iteration and released when the loop completes or exits early .
A reader gives more control:
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(value);
}
The read() method returns a promise that resolves to an object with value and done properties. When the stream is exhausted, done is true and value is undefined. The reader must be released when done to unlock the stream .
For byte-oriented streams, the ReadableStreamBYOBReader (“bring your own buffer”) provides a more efficient reading pattern. You pass a buffer to read(), and the stream fills it directly, avoiding an extra copy.
const reader = stream.getReader({ mode: 'byob' });
const buffer = new Uint8Array(1024);
const { value, done } = await reader.read(buffer);
This reader is only available when the stream was created with type: 'bytes' .
b. WritableStream: producing data
A WritableStream is a destination. You create it with an underlying sink that defines how chunks are written.
import { WritableStream } from 'node:stream/web';
const stream = new WritableStream({
write(chunk) {
console.log('Received:', chunk);
},
close() {
console.log('Stream closed');
}
});
The write method is called for each chunk written to the stream. It can be async; if it returns a promise, the next write waits for it to resolve. The close method is called when the stream is closed. The abort method handles abrupt termination .
Writing to a WritableStream uses a writer:
const writer = stream.getWriter();
await writer.write('Hello');
await writer.write('World');
await writer.close();
The write() method returns a promise that resolves when the chunk has been accepted by the sink. The close() method closes the stream after all pending writes complete. The ready property is a promise that resolves when the writer is ready to accept more data, useful for manual backpressure management .
The most common way to use a WritableStream is as the destination of a pipe:
await readableStream.pipeTo(writableStream);
The pipeTo method reads from the source and writes to the destination, handling backpressure automatically. It returns a promise that resolves when the source is exhausted and the destination is closed .
c. TransformStream and piping
A TransformStream is a readable/writable pair connected by a transform function. Data written to the writable side is passed through the transform function and emerges on the readable side.
import { TransformStream } from 'node:stream/web';
const transform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
The transform method receives each chunk and the controller. It can enqueue zero or more output chunks. The start method runs before any chunks are transformed. The flush method runs after all chunks have been transformed and before the readable side closes .
The typical pattern is to place a TransformStream in a pipe chain using pipeThrough:
const transformedStream = readableStream.pipeThrough(transform);
The pipeThrough method connects the readable stream to the transform’s writable side and returns the transform’s readable side. The original stream is locked for the duration of the pipe. This is how you compose stream processing pipelines .
A complete pipeline reads from a source, passes through one or more transforms, and writes to a destination:
await readableStream
.pipeThrough(transform1)
.pipeThrough(transform2)
.pipeTo(writableStream);
The pipeTo at the end returns a promise that resolves when the entire pipeline completes. Errors propagate through the chain and reject the promise.
The pipeThrough and pipeTo methods accept options that control error propagation. preventAbort prevents source errors from aborting the destination. preventCancel prevents destination errors from canceling the source. preventClose prevents source closure from closing the destination. These are useful when composing pipelines where the stages have independent lifecycles .
Complete Example Session
// ============================================
// PART 1: CREATING A READABLESTREAM
// ============================================
import { ReadableStream } from 'node:stream/web';
const source = new ReadableStream({
start(controller) {
controller.enqueue('a');
controller.enqueue('b');
controller.enqueue('c');
controller.close();
}
});
for await (const chunk of source) {
console.log(chunk);
}
// Prints: a b c
// ============================================
// PART 2: CONSUMING WITH A READER
// ============================================
const reader = source.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log('Read:', value);
}
reader.releaseLock();
// ============================================
// PART 3: CREATING A WRITABLESTREAM
// ============================================
import { WritableStream } from 'node:stream/web';
const sink = new WritableStream({
write(chunk) {
console.log('Wrote:', chunk);
},
close() {
console.log('Sink closed');
}
});
const writer = sink.getWriter();
await writer.write('Hello');
await writer.write('World');
await writer.close();
// ============================================
// PART 4: TRANSFORMSTREAM
// ============================================
import { TransformStream } from 'node:stream/web';
const upper = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
// ============================================
// PART 5: PIPETHROUGH
// ============================================
const source = new ReadableStream({
start(controller) {
controller.enqueue('hello');
controller.close();
}
});
const transformed = source.pipeThrough(upper);
for await (const chunk of transformed) {
console.log(chunk); // Prints: HELLO
}
// ============================================
// PART 6: PIPETO
// ============================================
const destination = new WritableStream({
write(chunk) {
console.log('Destination:', chunk);
}
});
await source.pipeTo(destination);
// ============================================
// PART 7: FULL PIPELINE
// ============================================
await source
.pipeThrough(upper)
.pipeThrough(new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk + '!');
}
}))
.pipeTo(destination);
// Prints: DESTINATION: HELLO!
// ============================================
// PART 8: ASYNC GENERATOR SOURCE
// ============================================
async function* generate() {
yield 'one';
yield 'two';
yield 'three';
}
const fromIterable = ReadableStream.from(generate());
for await (const chunk of fromIterable) {
console.log(chunk);
}
// ============================================
// PART 9: BYOB READER
// ============================================
const byteStream = new ReadableStream({
type: 'bytes',
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
controller.close();
}
});
const byobReader = byteStream.getReader({ mode: 'byob' });
const buffer = new Uint8Array(4);
const { value } = await byobReader.read(buffer);
console.log(value); // Uint8Array(4) [1, 2, 3, 4]
// ============================================
// PART 10: ERROR HANDLING
// ============================================
const failing = new TransformStream({
transform(chunk, controller) {
if (chunk === 'bad') {
controller.error(new Error('Bad chunk'));
} else {
controller.enqueue(chunk);
}
}
});
try {
await ReadableStream.from(['good', 'bad', 'good'])
.pipeThrough(failing)
.pipeTo(destination);
} catch (err) {
console.error('Pipeline failed:', err.message);
}
The ten parts covered creating a ReadableStream, consuming with a reader, creating a WritableStream, creating a TransformStream, pipeThrough, pipeTo, full pipeline composition, async generator sources, BYOB readers, and error handling.
Quick Reference
Core Classes
| Class | Purpose | Import |
|---|---|---|
ReadableStream | Source of data | node:stream/web |
WritableStream | Destination for data | node:stream/web |
TransformStream | Transform in a pipeline | node:stream/web |
Consumption Methods
| Method | Returns | Use Case |
|---|---|---|
for await | Async iteration | Simplest consumption |
getReader() | ReadableStreamDefaultReader | Manual control |
getReader({ mode: 'byob' }) | ReadableStreamBYOBReader | Byte-oriented, zero-copy |
read() | { value, done } | Read one chunk |
values() | Async iterator | Early exit control |
Production Methods
| Method | Returns | Use Case |
|---|---|---|
getWriter() | WritableStreamDefaultWriter | Manual control |
write(chunk) | Promise | Write one chunk |
close() | Promise | Close after writes |
abort(reason) | Promise | Abrupt termination |
Piping Methods
| Method | Returns | Use Case |
|---|---|---|
pipeTo(dest) | Promise | Connect to a WritableStream |
pipeThrough(transform) | ReadableStream | Connect through a TransformStream |
tee() | [ReadableStream, ReadableStream] | Split a stream into two |
TransformStream Hooks
| Hook | Called When | Purpose |
|---|---|---|
start(controller) | Stream created | Initialization |
transform(chunk, controller) | Chunk written | Transform logic |
flush(controller) | Before close | Emit suffix chunks |
Best Practices
✅ Do This:
// Use async iteration for simple consumption
for await (const chunk of stream) { } // ✅
// Use pipeThrough for transform chains
stream.pipeThrough(transform).pipeTo(dest) // ✅
// Use pipeTo to connect to a destination
await source.pipeTo(sink) // ✅
// Use BYOB reader for byte streams
const reader = stream.getReader({ mode: 'byob' }) // ✅
// Handle errors with try/catch
try { await source.pipeTo(sink) } catch (e) { } // ✅
// Release readers and writers when done
reader.releaseLock() // ✅
// Use ReadableStream.from for iterables
ReadableStream.from(asyncGenerator()) // ✅
❌ Don’t Do This:
// Don't forget to release locks
const reader = stream.getReader();
// ... no releaseLock() // ❌
// Don't mix Node.js streams and Web Streams without conversion
nodeReadable.pipe(webWritable) // ❌
// Don't use BYOB reader on non-byte streams
stream.getReader({ mode: 'byob' }) // ❌ (type must be 'bytes')
// Don't pass pooled Buffers to BYOB reader
reader.read(Buffer.allocUnsafe(1024)) // ❌ (detaches pool)
// Don't forget that pipeTo locks the source
await source.pipeTo(sink);
source.getReader() // ❌ (locked)
// Don't use Web Streams for very small data
// (overhead may exceed benefit) // ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Stream is locked | Active reader or pipe | Release lock or wait for pipe to complete |
pipeTo never resolves | Destination write pending | Ensure write resolves |
| BYOB read fails | Stream not byte-oriented | Create with type: 'bytes' |
| Pooled buffer detached | Passing Buffer.allocUnsafe to BYOB | Use Buffer.alloc or plain Uint8Array |
| Error not caught | Promise rejection unhandled | Wrap in try/catch |
| Transform not called | Missing transform method | Add the method to the transformer |
| Flush not called | No flush method | Add flush if needed |
Real-World Examples
1. HTTP Response Body
const response = await fetch(url);
for await (const chunk of response.body) {
process(chunk);
}
2. File Upload Streaming
const fileStream = file.stream();
await fileStream.pipeTo(new WritableStream({
write(chunk) { uploadChunk(chunk); }
}));
3. Line Splitter Transform
const lineSplitter = new TransformStream({
transform(chunk, controller) {
this.buffer += chunk;
const lines = this.buffer.split('\n');
this.buffer = lines.pop();
lines.forEach(line => controller.enqueue(line));
},
flush(controller) {
if (this.buffer) controller.enqueue(this.buffer);
}
});
4. JSON Parser Transform
const jsonParser = new TransformStream({
transform(chunk, controller) {
controller.enqueue(JSON.parse(chunk));
}
});
5. Tee for Multiple Consumers
const [stream1, stream2] = source.tee();
// Both receive identical data
6. Rate Limiting
const limiter = new TransformStream({
async transform(chunk, controller) {
await delay(100);
controller.enqueue(chunk);
}
});
7. Compression
const compressor = new TransformStream({
transform(chunk, controller) {
controller.enqueue(compress(chunk));
}
});
8. CSV to JSON
const csvToJson = new TransformStream({
transform(line, controller) {
const [name, age] = line.split(',');
controller.enqueue({ name, age: parseInt(age) });
}
});
9. Log Filter
const filterWarnings = new TransformStream({
transform(line, controller) {
if (line.includes('WARN')) controller.enqueue(line);
}
});
10. Progress Tracking
let total = 0;
const counter = new TransformStream({
transform(chunk, controller) {
total += chunk.length;
controller.enqueue(chunk);
}
});
Visual
The Three Stream Types
┌─────────────────────────────────────────────────────────────┐
│ READABLESTREAM │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Source → [queue] → Reader → Consumer │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Produces data. Consumed by reader or for await. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WRITABLESTREAM │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Producer → Writer → [queue] → Sink │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Consumes data. Written to by writer. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ TRANSFORMSTREAM │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Writable → transform() → Readable │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Connected pair. Write to writable, read from readable. │
│ │
└─────────────────────────────────────────────────────────────┘
Pipe Chain
┌─────────────────────────────────────────────────────────────┐
│ PIPELINE COMPOSITION │
│ │
│ ReadableStream ──▶ TransformStream ──▶ TransformStream │
│ │ │ │ │
│ │ │ │ │
│ └── pipeThrough ───┘ │ │
│ │ │ │
│ └── pipeThrough ───┘ │
│ │ │
│ ▼ │
│ WritableStream │
│ │ │
│ └── pipeTo ────▶│
│ │
│ Each pipeThrough returns the readable side. │
│ The final pipeTo connects to the destination. │
│ │
└─────────────────────────────────────────────────────────────┘
Backpressure Flow
┌─────────────────────────────────────────────────────────────┐
│ BACKPRESSURE │
│ │
│ Fast Producer Slow Consumer │
│ ┌──────────┐ ┌──────────┐ │
│ │ Read │ ────────▶ │ Write │ │
│ │ fast │ │ slow │ │
│ └──────────┘ └──────────┘ │
│ │
│ Without backpressure: │
│ Queue grows unbounded. Memory exhausted. │
│ │
│ With backpressure (automatic in pipeTo): │
│ 1. Write returns pending promise │
│ 2. Pipe waits for promise to resolve │
│ 3. Read pauses │
│ 4. Write completes │
│ 5. Read resumes │
│ │
│ No manual buffer management required. │
│ │
└─────────────────────────────────────────────────────────────┘
Web Streams vs Node.js Streams
┌─────────────────────────────────────────────────────────────┐
│ WEB STREAMS │
│ │
│ Promise-based: reader.read() → Promise │
│ Piping: pipeTo, pipeThrough │
│ Iteration: for await │
│ Transferable: postMessage │
│ Standard: WHATWG │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ NODE.JS STREAMS │
│ │
│ Event-based: 'data', 'end', 'error' │
│ Piping: .pipe() │
│ Iteration: for await (also supported) │
│ Transferable: no │
│ Standard: Node.js API │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ BRIDGING │
│ │
│ Readable.toWeb(nodeReadable) → Web ReadableStream │
│ Readable.fromWeb(webReadable) → Node.js Readable │
│ Writable.toWeb(nodeWritable) → Web WritableStream │
│ Writable.fromWeb(webWritable) → Node.js Writable │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Import | import { ReadableStream, WritableStream, TransformStream } from 'node:stream/web' |
| Status | Stable since Node.js 21 |
| ReadableStream | Source of data |
| WritableStream | Destination for data |
| TransformStream | Connected pair with transform function |
| Reading | for await, getReader().read() |
| Writing | getWriter().write() |
| Piping | pipeTo, pipeThrough |
| BYOB | Byte-oriented zero-copy reading |
| Transferable | Yes, via postMessage |
| Bridging | toWeb / fromWeb on Node.js streams |
Key takeaways:
- Web Streams are promise-based, Node.js streams are event-based. The Web Streams API uses
async/awaitthroughout. Reading returns a promise, writing returns a promise, and piping returns a promise. This makes error handling withtry/catchnatural. ReadableStreamis the source,WritableStreamis the destination,TransformStreamconnects them. A transform has a writable side and a readable side. Data written to the writable side is passed through the transform function and emerges on the readable side.pipeThroughcomposes transforms,pipeToconnects to a destination. A pipeline is a chain ofpipeThroughcalls ending with apipeTo. The finalpipeToreturns a promise that resolves when the entire pipeline completes.- Backpressure is automatic in pipes. When the destination is slow, the pipe pauses the source. You do not manage buffers manually. This is the primary benefit of using
pipeToinstead of manually reading and writing. - BYOB readers avoid copying for byte streams. When a stream is created with
type: 'bytes', you can use aReadableStreamBYOBReaderto read directly into a provided buffer. This is more efficient for binary data. - Streams are transferable between workers. A
ReadableStreamorWritableStreamcan be passed through apostMessagecall, allowing stream processing to happen in a different thread. - Web Streams and Node.js streams are not interchangeable. They have different APIs. Use
Readable.toWeb()andReadable.fromWeb()to convert between them when bridging is necessary. - The API is stable in Node.js 21. Earlier versions marked it experimental. If you are on Node.js 21 or later, the Web Streams API is production-ready.
Remember: The Web Streams API is a standard abstraction for streaming data across JavaScript environments. It is promise-based, composable, and designed for interoperability. In Node.js, it complements rather than replaces the native stream implementation. Use it when you need browser compatibility, when you prefer the promise-based ergonomics, or when you are working with fetch and other web APIs that produce web streams. Use Node.js streams when you need the mature ecosystem, the event-driven model, or the performance characteristics of the native implementation. The toWeb and fromWeb methods bridge the two, so you are not locked into either choice.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!