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.

6 min read

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:

javascriptjavascript
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.

Stream pipeline: readable to writable

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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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:

javascriptjavascript
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.

javascriptjavascript
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:

javascriptjavascript
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.

javascriptjavascript
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.

Quick reference

ConceptCode
Create readablenew ReadableStream({ start(ctrl) { ctrl.enqueue(x); ctrl.close(); } })
Create writablenew WritableStream({ write(chunk) {}, close() {} })
Create transformnew TransformStream({ transform(chunk, ctrl) { ctrl.enqueue(x); } })
Read chunksreader.read() returns { value, done }
Write chunksawait writer.write(chunk)
Pipe throughreadable.pipeThrough(transform)
Pipe toawait readable.pipeTo(writable)
Tee (split)const [a, b] = readable.tee()
Rune AI

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.
RunePowered by Rune AI

Frequently Asked Questions

Where is the Web Streams API available?

The Web Streams API is available in all modern browsers and in Node.js (v18+ with the global stream classes). It also works in web workers and service workers, making it useful for offline data processing.

How is the Web Streams API different from Node.js streams?

The Web Streams API is a browser-native standard with a promise-based interface. Node.js streams use an event-based interface with 'data' and 'end' events. Node.js now supports both APIs, but they are separate implementations.

What is backpressure in streams?

Backpressure is the mechanism that prevents a fast producer from overwhelming a slow consumer. When a writable stream's internal queue is full, the producer's write promise stays pending until the consumer catches up, naturally throttling the data flow.

Can I stream data from a fetch response?

Yes. fetch().then(response => response.body) returns a ReadableStream. You can pipe it through transform streams or read it chunk by chunk without waiting for the entire response to download.

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.