Streams: ReadableStream and WritableStream
A stream is a sequence of data that arrives gradually rather than all at once. A plain fetch().text() waits until everything has arrived and only then returns a result, while a stream (response.body) lets you process the data piece by piece, as it comes in, chunk by chunk.
Theory
TL;DR
- A stream is data arriving in chunks rather than in one block.
ReadableStreamis a source,WritableStreamis a sink,TransformStreamis a converter.response.bodyafterfetch()is a ready-madeReadableStream.reader.read()returns a promise with{ done, value }, wherevalueis aUint8Array.- Streams are chained with
pipeThrough()andpipeTo(). - The main benefit: processing large volumes without loading everything into memory.
Quick example
const response = await fetch('https://example.com/large-file.txt');
// response.body is a ReadableStream
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log('chunk size:', value.length);
}What happens:
fetchreturns aResponsewhose.bodyis a ReadableStream.- The
.getReader()method creates a reader that hands data back in chunks. reader.read()returns a promise with{ done, value }:valueis a piece of data (typeUint8Array);done: truemeans the stream is finished.
This is how you download gigabytes without holding the whole file in memory.
Stream types in JavaScript
| Type | Purpose |
|---|---|
| ReadableStream | data source (reading) |
| WritableStream | data sink (writing) |
| TransformStream | intermediate processing (read plus write) |
| DuplexStream | two-way, a socket for example |
Writing data to a WritableStream
const stream = new WritableStream({
write(chunk) {
console.log('writing chunk:', chunk);
},
close() {
console.log('stream finished');
},
});
const writer = stream.getWriter();
await writer.write('Hello');
await writer.write('World');
await writer.close();Explanation:
WritableStreamaccepts data in pieces throughwrite(chunk);close()is called when the stream is finished;- it is handy for sending data, writing files, logging and so on.
TransformStream and converting on the fly
const upperCaseStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});
const writer = upperCaseStream.writable.getWriter();
const reader = upperCaseStream.readable.getReader();
await writer.write('hello ');
await writer.write('world!');
await writer.close();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value); // "HELLO " "WORLD!"
}A TransformStream joins an input stream (writable) and an output stream (readable) and lets you convert data on the fly.
Piping streams with pipeTo and pipeThrough
Streams can be connected to each other like water pipes:
await readableStream
.pipeThrough(transformStream)
.pipeTo(writableStream);In other words:
pipeThroughapplies a transformation and returns a new readable;pipeTosends the result into a final sink.
A practical example with fetch() and TextDecoderStream:
const response = await fetch('https://example.com/data.txt');
const textStream = response.body
.pipeThrough(new TextDecoderStream()) // decode bytes into text
.pipeThrough(
new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase()); // transform the text
},
})
);
const reader = textStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value);
}Every piece of the file is first turned into text, then uppercased, and processed immediately, without waiting for the download to finish.
Streams in Node.js and why they matter
Node.js has its own implementation (fs.createReadStream, stream.Writable and others), but the concept is the same:
import fs from 'fs';
const readStream = fs.createReadStream('file.txt', { encoding: 'utf-8' });
readStream.on('data', chunk => console.log('Chunk:', chunk));
readStream.on('end', () => console.log('Done'));Node.js streams are built on EventEmitter, while browser streams are built on promises and controllers. Modern Node.js also ships Web Streams through node:stream/web, so the code can move between environments.
| Advantage | Description |
|---|---|
| Performance | No need to wait for a whole file to download |
| Low memory use | Processing happens on the fly, in chunks |
| Composition | Streams can be joined into chains |
| Two-way communication | Used by WebSocket, fetch(), Response, Body |
| Control | Reading can be paused and resumed (backpressure) |
A simple analogy: streams are a conveyor belt. The data does not arrive whole, it travels along the belt in chunks, and at every stage (Readable, Transform, Writable) you can process, filter and store it.
| Type | Purpose | Example use |
|---|---|---|
| ReadableStream | Data source | fetch().body, file.stream() |
| WritableStream | Data sink | Writing to a file, a log, the network |
| TransformStream | Converter | Compression, encryption, filtering |
| pipeTo / pipeThrough | Connecting streams | readable.pipeThrough(transform).pipeTo(writable) |
Common mistakes
- Reading
response.bodyafterresponse.text(). A response body can only be consumed once: aftertext(),json()orblob()the stream is already locked. If you need both, useresponse.clone(). - Forgetting to release the reader. While
getReader()is held, the stream stays locked. Callreader.releaseLock()or read the stream to the end. - Ignoring backpressure. Writing into a
WritableStreamwithoutawait writer.write(...)lets the queue grow, and the memory advantage of streaming disappears. - Assuming
valueis a string.response.bodyyieldsUint8Array; to get text you needTextDecoderStreamorTextDecoder. - Not handling errors in the chain.
pipeTo()returns a promise that can reject; without acatch, a network failure goes unnoticed. - Confusing Node.js streams with Web Streams. They have different APIs: the first works through
dataandendevents, the second through promises and controllers.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.