The Streams API: readable, writable, and transform streams
The Streams API gives JavaScript the ability to process data chunk-by-chunk as it arrives, rather than waiting for an entire response to load. Before this API, you had to download a whole file, string, or blob before doing anything with it. With Streams, you can start processing the moment the first byte arrives. This matters a lot when you are handling large files, streaming audio and video, or building data pipelines. For related async processing patterns, see the event loop guide.
Why streams matter
Imagine fetching a 500MB log file. Without streams, you wait for all 500MB before doing anything. With the Streams API, you can process the first chunk as soon as it arrives. Video playback has worked this way forever: the browser streams video frames while playing them. The Streams API brings that same capability to JavaScript.
The benefits:
- Start working on data as soon as the first chunk arrives
- No need to buffer entire responses in memory
- Automatically slows down producers when consumers can’t keep up
- Pipe streams together into transformation chains
ReadableStream: consuming data chunk by chunk
The Response.body property of a fetch response is a ReadableStream. That’s the easiest way to get a stream:
const response = await fetch('https://example.com/large-file.txt');
const stream = response.body; // ReadableStream
Getting the stream is only the first step. To actually read data from it, you need a reader: an object that pulls chunks one at a time and lets you know when the stream is exhausted. The pattern below loops until done is true, processing each Uint8Array chunk as it arrives:
const response = await fetch('https://example.com/data.txt');
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value is a Uint8Array chunk
console.log('Received chunk:', value);
}
read() returns a promise that resolves to { done, value }:
{ done: false, value: chunk }: a chunk is available{ done: true, value: undefined }: stream is closed- rejected: an error occurred in the stream
The manual reader loop works well, but ReadableStream also supports the async iterator protocol. A for await...of loop reads the same chunks with less boilerplate. The loop body receives each chunk directly as a Uint8Array, and the iteration stops automatically when the stream closes. If you do not need fine-grained control over cancellation mid-stream, the iterator form is usually the cleaner choice:
const response = await fetch('https://example.com/data.txt');
for await (const chunk of response.body) {
// chunk is a Uint8Array
console.log('Chunk:', chunk);
}
Both the manual reader and the iterator keep reading until the stream ends or an error occurs. Sometimes you want to stop partway through, for example, when a user navigates away or the data you need has already arrived. The AbortController API lets you cancel the underlying HTTP request, which tears down the stream cleanly without leaving dangling network resources:
const response = await fetch('https://example.com/large-file.txt');
const reader = response.body.getReader();
const aborter = new AbortController();
setTimeout(() => aborter.abort(), 5000); // cancel after 5s
try {
for await (const chunk of response.body) {
// process
}
} catch (err) {
if (err.name === 'AbortError') {
console.log('Cancelled');
}
}
Cancellation gives you control over when reading stops. The other side of the Streams API handles the opposite direction: writing chunks to a destination. A WritableStream wraps a sink: a logging target, a file handle, or a network socket, and manages the flow of chunks into that sink, applying backpressure automatically when the consumer cannot keep up with incoming data.
WritableStream: writing data
WritableStream is the destination side of streams. You write chunks to it, and it sends them to the underlying sink:
const writable = new WritableStream({
write(chunk) {
console.log('Writing:', chunk);
},
close() {
console.log('Done writing');
}
});
const writer = writable.getWriter();
await writer.write('Hello');
await writer.write('World');
await writer.close();
WritableStream has built-in backpressure. If the underlying sink is slower than data arriving, write() waits until the sink is ready.
Often the most useful pattern connects a readable and a writable stream through a transformation stage. A TransformStream reads from one side, applies a function to each chunk, and writes the result to the other side. It eliminates the need to write custom reader and writer logic for every pipeline; you define the transformation, and the stream handles the plumbing.
TransformStream: chaining data through a pipeline
TransformStream sits between a readable and writable stream and transforms data as it passes through:
const upperCaseTransform = new TransformStream({
transform(chunk, controller) {
const text = new TextDecoder().decode(chunk);
controller.enqueue(new TextEncoder().encode(text.toUpperCase()));
}
});
Defining a transform is one step. To use it, you insert the transform into a pipeline with pipeThrough(). The method returns a new readable stream, so you can chain multiple transforms before sending the result to a destination. The example below runs an uppercase conversion followed by a text encoder, then pipes the final output to a writable sink:
const response = await fetch('https://example.com/data.txt');
const readable = response.body
.pipeThrough(upperCaseTransform)
.pipeThrough(new TextEncoderStream())
.pipeTo(writableDestination);
pipeThrough() returns the readable side. pipeTo() connects the final readable to a writable and returns a promise when complete.
So far we have consumed built-in streams from fetch responses. You can also create your own readable stream from scratch by implementing the low-level start, pull, and cancel methods. This pattern is useful when the data source is not an HTTP response (a counter, a sensor feed, or a computed sequence) and you want it to participate in the same stream pipeline as everything else.
Building a custom ReadableStream
function generateNumbers(count) {
return new ReadableStream({
start(controller) {
this.current = 0;
},
pull(controller) {
if (this.current >= count) {
controller.close();
return;
}
controller.enqueue(this.current++);
},
cancel(reason) {
console.log('Cancelled:', reason);
}
});
}
const stream = generateNumbers(10);
for await (const num of stream) {
console.log(num); // 0, 1, 2, ... 9
}
start() runs once when the stream is created. pull() is called repeatedly as the consumer requests more data. cancel() fires if the consumer stops reading before the stream finishes. Your cleanup logic lives here.
A custom stream gives you full control over what gets produced and when. Most real-world usage, however, starts with a stream you already have: a fetch response body. The function below collects every chunk from a stream into an array, a pattern you will reach for whenever you need the full payload rather than incremental processing:
Reading from a fetch response as a stream
async function streamResponse(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return chunks;
}
Collecting chunks manually works, but pipeTo() automates the transfer between a readable and a writable stream. It handles all the edge cases: reading chunk by chunk, waiting when the destination is busy, and cleaning up if either side fails. For simple copy operations, a single pipeTo() call replaces the entire reader loop.
pipeTo for automatic backpressure
const source = await fetch('https://example.com/large.txt').then(r => r.body);
const dest = new WritableStream({ write(chunk) { /* save chunk */ } });
await source.pipeTo(dest);
No extra code needed. If dest cannot keep up, pipeTo() slows the source.
Sometimes one stream is not enough. You may need to feed the same data into two different consumers simultaneously. The tee() method splits a ReadableStream into two independent copies. Both copies receive every chunk from the original, and each can be read at its own pace without blocking the other:
Teeing a stream
const [copy1, copy2] = stream.tee();
const reader1 = copy1.getReader();
const reader2 = copy2.getReader();
Both copies can be consumed independently. Once teed, the original stream is locked.
Now that the core stream types are clear, two practical patterns show how they combine in real code. The first reader produces lines from a text stream, useful for log files and CSV data. The second chains transforms to process rows without loading the entire file into memory.
Common Patterns
Stream lines from a body
async function* streamLines(stream) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let remainder = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = remainder + decoder.decode(value, { stream: true });
const lines = text.split('\n');
remainder = lines.pop();
for (const line of lines) yield line;
}
if (remainder) yield remainder;
}
The line reader handles raw text. The next step is parsing structured data from those lines. A CSV parser sits downstream of the text stream, transforming each row into an object so the consumer never touches raw bytes or strings. The pattern below chains a TextDecoderStream and a hypothetical CSVParserTransform — the result is an async iterable of row objects ready for business logic:
async function processCSV(url) {
const response = await fetch(url);
const stream = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new CSVParserTransform());
for await (const row of stream) {
// process one row at a time
}
}
Browser Support
Streams API is widely supported (Chrome 43+, Firefox 65+, Safari 16.4+). The ReadableStream.from() static method is experimental and converts any iterable directly to a stream.
Choosing a stream boundary
The hardest part of stream design is often deciding where to split the work. A good boundary lets one stage focus on reading, another on transforming, and another on writing. If a stage tries to handle every step, it becomes harder to compose and harder to debug. Small stages are easier to swap in and out when the shape of the data changes.
That boundary also affects memory use. If you buffer too much too early, you lose the main advantage of streaming. If you split too often, you can add unnecessary overhead. The right middle ground is usually the one that keeps each stage simple while still letting chunks move through the pipeline without waiting for the whole payload.
Errors and Cleanup
Streams are most reliable when the code treats errors as part of the flow, not as an afterthought. If one stage fails, the rest of the pipeline should stop cleanly and free any resources it was holding. That means readers, writers, and custom sources should all be prepared for early exit. Cleanup is not just about memory. It is about not leaving a half-finished pipeline behind.
This is especially important in long-running apps. A failed stream that leaves listeners, timers, or open handles behind can cause the next run to behave strangely. A clear error path keeps each stream bounded and makes repeated use much safer.
Text, bytes, and transforms
It is easy to forget that streams are not limited to text. Many of them move raw bytes, and the transform stages decide how those bytes become something meaningful. A TextDecoderStream or TextEncoderStream can make that boundary explicit, which helps keep the logic readable. Once you know which stage owns decoding, the rest of the chain becomes easier to reason about.
That clarity matters when you build more than one pipeline. If every stage knows whether it expects bytes or strings, you avoid a lot of accidental conversion bugs. The stream model works best when each stage has one clear job and the data type at each boundary is obvious.
Partial reads and resilience
Real stream consumers rarely read the whole source in one perfect pass. They may stop early, retry after an error, or only need part of the data. A good stream design makes those cases feel normal instead of exceptional. The reader should be able to stop without confusing the rest of the app, and the producer should still clean up its own work.
That kind of resilience is especially useful when the source is expensive. If you can stop after the first few chunks, you save time and memory. If the stream can be restarted cleanly, the code around it becomes easier to trust during development and easier to use in production.
Stream design in practice
The best stream pipelines usually stay easy to describe. One stage reads, one stage transforms, and one stage writes. If a stage starts taking on extra jobs, the whole pipeline gets harder to debug and harder to adapt. Keeping the responsibilities narrow makes the flow much easier to revisit later.
That discipline pays off when the source changes shape or the destination slows down. The simpler the pipeline, the easier it is to adjust one stage without rewriting the others.
See Also
- javascript-async-iterators: the async iteration protocol that underlies
for await...ofwith streams - javascript-web-animations-api: another browser native API with similar patterns
- javascript-performance-api: measure stream processing performance