JavaScript Streams API
Learn the JavaScript Streams API — read data progressively with ReadableStream, write with WritableStream, and transform data efficiently.
The Streams API lets you process data in small chunks as it arrives, instead of loading everything into memory at once. This is essential for working with large files, slow network responses, and real-time data: you can start handling the first bytes while the rest are still in transit, and you never have to hold the whole payload in memory.
The API is built around three core types. A ReadableStream is a source you pull data from. A WritableStream is a sink you push data into. A TransformStream sits in the middle, taking chunks in one end and emitting modified chunks out the other. Once you understand these three, you can compose them into efficient pipelines.
Reading a Stream
The most common way to get a stream is the Fetch API. A Response object exposes its body as a ReadableStream through response.body, so you can consume the download chunk by chunk rather than waiting for the whole thing with response.text().
To read manually, call getReader() to lock a reader to the stream, then loop on reader.read(). Each call resolves to an object with done and value:
const response = await fetch('/large-file.txt');
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value is a Uint8Array chunk of bytes
console.log('Received', value.length, 'bytes');
}Each value is a Uint8Array — a chunk of raw bytes, not a string (see Typed arrays). When done is true, the stream is finished and value is undefined. To turn the bytes into text you typically use a TextDecoder, which can stitch chunks together even when a multi-byte character is split across two reads:
This same loop is how you build download progress indicators: sum the length of each chunk and compare it against the Content-Length header.
Async Iteration
In modern environments a ReadableStream is async-iterable, so you can replace the manual reader loop with for await...of (see async iterators and generators):
const response = await fetch('/large-file.txt');
for await (const chunk of response.body) {
// chunk is a Uint8Array
console.log('Received', chunk.length, 'bytes');
}This is cleaner because the loop handles done for you and releases the reader automatically. The catch is support: Node.js handles this well, but direct async iteration over response.body is still uneven across browsers.
Because browser support for async-iterating streams is inconsistent, the getReader() loop remains the most portable form. Reach for for await...of in Node or when you control the runtime; fall back to a reader in code that must run everywhere.
Creating a ReadableStream
You can build your own source by passing an underlying source object to the ReadableStream constructor. It can define three optional methods:
start(controller)runs once when the stream is created — good for setup or for pushing initial data.pull(controller)is called whenever the consumer wants more data and the internal queue has room.cancel(reason)runs if the consumer stops reading early, so you can clean up.
You push data with controller.enqueue(chunk) and signal the end with controller.close():
A stream can carry any JavaScript value, not just bytes — here it emits plain numbers. When the source is slow or open-ended (a WebSocket, a timer, sensor data), put the logic in pull() so chunks are produced only as the consumer asks for them.
TransformStream
A TransformStream modifies chunks as they pass through. You give it a transform(chunk, controller) function that receives each incoming chunk and calls controller.enqueue() with the transformed result:
const upperCaser = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});A transform stream exposes a writable end (where chunks go in) and a readable end (where they come out), which is exactly what makes piping possible.
The platform ships several ready-made transforms so you rarely write byte-level logic by hand:
TextDecoderStream/TextEncoderStreamconvert between byte chunks and text chunks.CompressionStream/DecompressionStreamapply gzip or deflate on the fly.
Piping Streams Together
Instead of wiring readers and writers manually, you can connect streams directly. There are two methods:
readable.pipeTo(writable)sends every chunk from a readable stream into a writable stream and resolves a promise when it finishes.readable.pipeThrough(transformStream)runs the data through a transform and returns a new readable stream — perfect for chaining.
Combining pipeThrough with TextDecoderStream gives you text chunks straight from a network response, with no manual decoder bookkeeping:
const response = await fetch('/large-file.txt');
const textStream = response.body.pipeThrough(new TextDecoderStream());
for await (const textChunk of textStream) {
console.log(textChunk); // already a string
}You can chain as many stages as you like — for example response.body.pipeThrough(new DecompressionStream('gzip')).pipeThrough(new TextDecoderStream()) to decompress and decode in one declarative pipeline.
Backpressure
A key advantage of streams over buffering everything is backpressure. When the consumer is slow, the stream automatically signals the source to pause producing, and resumes once the queue drains. With pipeTo and pipeThrough this happens for you — a fast download won't outrun a slow disk write and blow up memory.
Backpressure is why streaming a multi-gigabyte file uses only a small, bounded amount of memory. The producer never gets more than a few chunks ahead of the consumer, no matter how large the total payload is.
Use Cases
Streams shine whenever data is large, slow, or continuous:
- Progressive rendering — display the start of a big response while the rest is still arriving, instead of staring at a blank screen.
- Downloads and uploads with progress — measure bytes as they flow to drive a progress bar.
- Processing big files — handle a file chunk by chunk so memory stays flat even for files larger than RAM.
- Compression pipelines — pipe through
CompressionStreamorDecompressionStreamto gzip data as it streams.
Browser and Environment Support
ReadableStream, WritableStream, and TransformStream are supported in all modern browsers and in Node.js (where they are also exposed via node:stream/web). The pieces to watch are the newer additions: async iteration over response.body and CompressionStream arrived later, so check support or provide a getReader() fallback when you need broad coverage. Streams are closely related to Blobs — blob.stream() returns a ReadableStream, letting you bridge file-like objects into a streaming pipeline.