JavaScript Web Streams API: A Complete Tutorial
Learn how to process data in chunks with the Web Streams API. Use ReadableStream, WritableStream, and TransformStream for memory-efficient data handling.
The Web Streams API lets you process data one chunk at a time instead of loading everything into memory. Whether you are reading a large file, handling a network response, or transforming data on the fly, streams keep your memory usage low and your app responsive.
Here is a simple readable stream that produces three numbers:
const stream = new ReadableStream({
start(controller) {
controller.enqueue(1);
controller.enqueue(2);
controller.enqueue(3);
controller.close();
},
});
const reader = stream.getReader();
reader.read().then(({ value, done }) => {
console.log(value, done);
});The start function runs when the stream is created and pushes three chunks into the stream, then closes it. The getReader method returns a reader that pulls chunks one at a time. Each read call returns a promise with the chunk value and a done flag.
How streams work
A stream is a pipeline of chunks flowing from a producer to a consumer. The three core types form a processing chain: a ReadableStream produces chunks, an optional TransformStream modifies them in transit, and a WritableStream consumes them at the end.
The arrow from the WritableStream back to the ReadableStream represents backpressure. When the consumer cannot keep up, the writable stream signals upstream to slow down. The entire pipeline throttles naturally without buffers growing unbounded.
Creating a readable stream
A ReadableStream needs a start method that receives a controller. The controller has two important methods: enqueue pushes a chunk into the stream, and close signals that no more chunks are coming.
const numberStream = new ReadableStream({
start(controller) {
for (let i = 1; i <= 5; i++) {
controller.enqueue(i);
}
controller.close();
},
});This stream produces the numbers 1 through 5 as individual chunks. In a real application, chunks might be bytes from a file, lines of text, or parsed JSON objects.
Reading from a stream
The consumer side uses a reader obtained from getReader. Each call to read returns a promise that resolves with an object containing the value and a done boolean.
async function consumeStream(stream) {
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log("Received chunk:", value);
}
}
consumeStream(numberStream);The while loop reads chunks until done is true, which happens after the producer calls controller.close. This pattern works for streams of any size because only one chunk is in memory at a time.
Writing to a writable stream
A WritableStream receives chunks through a writer. Create the stream with write and close handlers, then get a writer to send data into it.
const writable = new WritableStream({
write(chunk) {
console.log("Writing chunk:", chunk);
},
close() {
console.log("Stream finished");
},
});Now use the writer to send chunks one at a time. Each write call is awaited so backpressure works correctly:
const writer = writable.getWriter();
await writer.write("chunk one");
await writer.write("chunk two");
await writer.close();Each call to write returns a promise that resolves when the stream's internal queue has room. This is backpressure in action: if the consumer processes chunks slowly, the write promise stays pending, and the producer naturally waits.
Transforming data with TransformStream
A TransformStream sits between a readable and writable stream, receiving chunks, modifying them, and passing them along. It is the most common way to build stream processing pipelines.
const uppercaseTransform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});The transform method runs once per chunk. Whatever it passes to controller.enqueue becomes the chunk that downstream consumers receive, so this transform swaps every string for its uppercase version. Connect it to a source stream with pipeThrough to see it work:
const readable = new ReadableStream({
start(controller) {
controller.enqueue("hello");
controller.enqueue("world");
controller.close();
},
});
const transformed = readable.pipeThrough(uppercaseTransform);
consumeStream(transformed);The pipeThrough method connects the readable stream to the transform, producing a new readable stream with the transformed chunks. The output stream yields "HELLO" and "WORLD" without the original readable stream knowing anything about the transformation.
Piping and teeing
The pipeTo method drains a readable stream directly into a writable stream. The tee method splits a readable stream into two independent copies.
const [branch1, branch2] = readable.tee();
consumeStream(branch1);
consumeStream(branch2);Teeing lets two consumers read the same data independently. Each branch gets its own copy of every chunk in order. This is useful for scenarios like logging data while also processing it.
Common mistakes
- Calling getReader twice on the same stream. A ReadableStream can only have one active reader at a time. Use tee to create two branches for parallel consumption.
- Forgetting to handle backpressure. Always await write calls on a writable stream. Synchronous writes in a loop can overflow the internal queue.
- Mixing read and pipeThrough on the same stream. Once a stream is piped, it is locked to that pipe. Release the lock or use tee first.
- Expecting streams to work synchronously. Streams are promise-based. Use async/await or promise chains throughout.
Related articles
- JavaScript Fetch API: Complete Tutorial -- fetch responses expose their body as a ReadableStream
- Handling JSON API Responses in JavaScript -- processing streamed JSON responses
- Advanced Web Workers for High Performance JS -- running stream processing off the main thread
Quick reference
| Concept | Code |
|---|---|
| Create readable | new ReadableStream({ start(ctrl) { ctrl.enqueue(x); ctrl.close(); } }) |
| Create writable | new WritableStream({ write(chunk) {}, close() {} }) |
| Create transform | new TransformStream({ transform(chunk, ctrl) { ctrl.enqueue(x); } }) |
| Read chunks | reader.read() returns { value, done } |
| Write chunks | await writer.write(chunk) |
| Pipe through | readable.pipeThrough(transform) |
| Pipe to | await readable.pipeTo(writable) |
| Tee (split) | const [a, b] = readable.tee() |
Rune AI
Key Insights
- ReadableStream produces data in chunks through a pull-based model controlled by the consumer.
- WritableStream consumes chunks and handles backpressure by pausing the producer when full.
- TransformStream wraps a readable and writable side, allowing inline data transformation.
- Use pipeThrough to connect streams and pipeTo to drain a readable stream into a writable one.
- Streams process data incrementally, so they never need to hold the entire dataset in memory.
Frequently Asked Questions
Where is the Web Streams API available?
How is the Web Streams API different from Node.js streams?
What is backpressure in streams?
Can I stream data from a fetch response?
Conclusion
The Web Streams API lets you process data as it arrives rather than waiting for the complete payload. ReadableStream produces chunks, WritableStream consumes them, and TransformStream sits between them to modify data in transit. Combined with piping and backpressure, streams handle large datasets with minimal memory usage.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.