JavaScript Streams: Readable, Writable, and Transform Streams
JavaScript streams let you process data piece by piece instead of waiting for an entire file, response, or input source to finish first. That makes them useful for large downloads, real-time data, and efficient browser or server-side processing.
Quick answer: A ReadableStream gives you data, a WritableStream accepts data, and a TransformStream changes data as it passes through. Streams help you handle data incrementally and avoid loading everything into memory at once.
Difficulty: Intermediate
You'll understand this better if you know: basic JavaScript functions, promises, and how asynchronous code works.
1. What Are JavaScript Streams?
JavaScript streams are interfaces for handling data over time. Instead of working with one large complete value, you work with chunks that arrive, move, or change step by step.
- ReadableStream produces data from a source.
- WritableStream consumes data and sends it somewhere.
- TransformStream reads data, modifies it, and passes it on.
- Streams are asynchronous by nature, so they fit well with network responses and background processing.
In the browser, streams are part of the Web Streams API. In Node.js, there is also a streams system with similar ideas, but the API is not identical.
2. Why JavaScript Streams Matter
Streams matter because many real tasks involve data that is too large, too slow, or too continuous to handle as a single value. If you wait for everything to finish before starting work, your app can become slower and use more memory than necessary.
Streams are especially useful when you want to:
- download a large file without buffering it all at once
- process a long response as it arrives
- pipe data through a formatter, encoder, or parser
- build efficient browser features like live previews or incremental rendering
They are less useful when the data is tiny and already available as a simple string, array, or object. In that case, plain values are usually easier to work with.
3. Basic Syntax or Core Idea
At the core, a stream has a producer, a consumer, or both. The most common pattern is to read chunks from a readable stream and then write or transform them.
Creating and reading a readable stream
This example creates a readable stream that emits a few text chunks, then reads them with a reader.
const stream = new ReadableStream({
start(controller) {
controller.enqueue("Hello");
controller.enqueue(", ");
controller.enqueue("world!");
controller.close();
}
});
const reader = stream.getReader();
async function readStream() {
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
result += value;
}
console.log(result);
}
readStream();This code uses enqueue() to add chunks and close() to signal that no more data is coming. The reader then pulls each chunk until done becomes true.
Writing to a writable stream
A writable stream receives chunks from another source. Here is a simple custom writable stream that logs each chunk.
const sink = new WritableStream({
write(chunk) {
console.log("Received:", chunk);
},
close() {
console.log("Stream closed");
}
});
const writer = sink.getWriter();
await writer.write("A");
await writer.write("B");
await writer.close();The writable stream handles each chunk in order. The writer returns promises so you can wait for backpressure and completion.
Transforming a stream
A transform stream lets you change chunks as they move through. This is useful for text conversion, filtering, or parsing.
const upperCaseTransform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
const source = new ReadableStream({
start(controller) {
controller.enqueue("hello stream");
controller.close();
}
});
const transformed = source.pipeThrough(upperCaseTransform);
const transformedReader = transformed.getReader();
console.log(await transformedReader.read());Here, pipeThrough() connects the readable stream to the transform stream and produces a new readable stream.
4. Step-by-Step Examples
Example 1: Reading a fetch response as a stream
Fetch responses often arrive as streams. This lets you begin processing the body before the full response is available.
const response = await fetch("/large-report.txt");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
console.log(text);This pattern is useful when the response may be large. The decoder handles byte chunks and converts them into text correctly.
Example 2: Piping a readable stream into a writable stream
When you want to connect a source directly to a destination, use pipeTo(). This reduces manual looping.
const source = new ReadableStream({
start(controller) {
controller.enqueue("first line\n");
controller.enqueue("second line\n");
controller.close();
}
});
const sink = new WritableStream({
write(chunk) {
console.log("Saving:", chunk);
}
});
await source.pipeTo(sink);This is a clean way to connect a producer and consumer without manually reading and writing every chunk.
Example 3: Transforming text before saving it
A transform stream can normalize or filter data before it reaches the destination.
const trimTransform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.trim());
}
});
const source = new ReadableStream({
start(controller) {
controller.enqueue(" alpha ");
controller.enqueue(" beta ");
controller.close();
}
});
const sink = new WritableStream({
write(chunk) {
console.log("Cleaned value:", chunk);
}
});
await source.pipeThrough(trimTransform).pipeTo(sink);The transform removes extra spaces before the data reaches the writable stream.
Example 4: Writing data incrementally
Some tasks produce output one piece at a time, such as logs or generated text. A writable stream handles that naturally.
const output = new WritableStream({
write(chunk) {
console.log("Output chunk:", chunk);
}
});
const writer = output.getWriter();
await writer.write("Hello ");
await writer.write("from ");
await writer.write("a stream");
await writer.close();The calls happen in order, and each promise helps coordinate flow control.
5. Practical Use Cases
- Processing a large file upload or download chunk by chunk.
- Streaming a network response to the UI without waiting for the whole payload.
- Transforming text, JSON fragments, or binary data before storage.
- Building progress indicators for long-running data transfers.
- Connecting compression, decoding, or filtering steps into a pipeline.
These are all situations where incremental work is better than waiting for a complete data set.
6. Common Mistakes
Mistake 1: Trying to read a stream twice without releasing it
When you call getReader(), the stream becomes locked to that reader. Beginners often try to attach another reader or reuse the stream immediately.
Problem: The stream is locked, so another reader cannot take control until the first one is released. This often leads to confusion when code stops working or throws a lock-related error.
const stream = new ReadableStream({
start(controller) {
controller.enqueue("data");
controller.close();
}
});
const reader1 = stream.getReader();
const reader2 = stream.getReader();Fix: Read with one reader at a time, then release the lock if you need to reuse the stream interface.
const stream = new ReadableStream({
start(controller) {
controller.enqueue("data");
controller.close();
}
});
const reader = stream.getReader();
await reader.read();
reader.releaseLock();The corrected version works because the lock is released before the stream is reused.
Mistake 2: Forgetting that fetch body data is binary chunks
Response bodies are usually byte chunks, not plain strings. If you concatenate them directly, the result will not be readable text.
Problem: The chunk value from a response body is typically a Uint8Array, so string operations do not behave the way beginners expect.
const response = await fetch("/message.txt");
const reader = response.body.getReader();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += value;
}Fix: Convert byte chunks with TextDecoder before adding them to a string.
const response = await fetch("/message.txt");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();The fixed version works because it converts bytes into text correctly, including the final buffered characters.
Mistake 3: Writing to a stream without waiting for backpressure
Writable streams can signal that they are busy. If you ignore the promise returned by write(), you may queue too much data too quickly.
Problem: This can cause slowdowns, memory growth, or out-of-order assumptions in code that depends on completion.
const sink = new WritableStream({
write(chunk) {
return new Promise(resolve => setTimeout(resolve, 100));
}
});
const writer = sink.getWriter();
writer.write("a");
writer.write("b");
writer.write("c");Fix: Use await so each write finishes before the next one starts.
const sink = new WritableStream({
write(chunk) {
return new Promise(resolve => setTimeout(resolve, 100));
}
});
const writer = sink.getWriter();
await writer.write("a");
await writer.write("b");
await writer.write("c");The corrected version respects flow control and avoids overwhelming the sink.
7. Best Practices
Practice 1: Prefer piping when you do not need manual control
When data just needs to move from one place to another, pipeTo() and pipeThrough() are easier to read and less error-prone than manual loops.
await source.pipeThrough(transform).pipeTo(destination);This keeps the data flow clear and lets the stream system manage flow control for you.
Practice 2: Decode bytes explicitly
Do not assume a readable stream always gives you strings. Text from fetch bodies and other byte sources should be decoded with TextDecoder.
const decoder = new TextDecoder();
const text = decoder.decode(chunk);This avoids garbled output and makes byte-based streams safe to work with as text.
Practice 3: Close streams when the source is done
A stream that never closes can leave consumers waiting forever. Closing tells the next step that no more data is coming.
const stream = new ReadableStream({
start(controller) {
controller.enqueue("done");
controller.close();
}
});Closing the stream makes completion predictable and prevents hanging consumers.
8. Limitations and Edge Cases
- A stream can be locked after getReader() or getWriter(), so you cannot freely share it between consumers.
- Some APIs expose stream bodies only once, so reading the body consumes it.
- Browser support is good for the Web Streams API, but older environments may not support every method equally well.
- Binary chunks and text chunks behave differently, so always check what type of data your stream carries.
- pipeTo() and pipeThrough() are convenient, but they are not a replacement for custom parsing logic when you need fine-grained control.
- Errors inside a transform or writable stream can reject the whole pipeline, so a single failure may stop downstream processing.
9. Practical Mini Project
In this mini project, we will build a small text pipeline that takes input, converts it to uppercase, and sends it to a writable stream that stores the final result in memory.
async function runPipeline() {
let saved = "";
const source = new ReadableStream({
start(controller) {
controller.enqueue("hello ");
controller.enqueue("from streams");
controller.close();
}
});
const uppercase = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
const destination = new WritableStream({
write(chunk) {
saved += chunk;
}
});
await source.pipeThrough(uppercase).pipeTo(destination);
return saved;
}
runPipeline().then(result => console.log(result));This pipeline shows the full pattern: a readable source produces chunks, a transform changes them, and a writable sink collects the result. The expected output is HELLO FROM STREAMS.
10. Key Points
- Readable streams produce data over time.
- Writable streams receive data over time.
- Transform streams change data as it moves through the pipeline.
- Streams help reduce memory usage and support incremental processing.
- Use pipeTo() and pipeThrough() when you want clean data flow.
- Remember to handle locks, decoding, and closing correctly.
11. Practice Exercise
- Create a readable stream that emits the words "learn", " "/, and "streams".
- Use a transform stream to convert each chunk to lowercase.
- Write the final output into a writable stream that joins the chunks into one string.
- Print the final result to the console.
Expected output: learn streams
Hint: Use pipeThrough() to connect the readable stream to the transform stream, then pipeTo() for the writable stream.
async function exercise() {
let result = "";
const source = new ReadableStream({
start(controller) {
controller.enqueue("learn");
controller.enqueue(" ");
controller.enqueue("streams");
controller.close();
}
});
const lowercase = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toLowerCase());
}
});
const destination = new WritableStream({
write(chunk) {
result += chunk;
}
});
await source.pipeThrough(lowercase).pipeTo(destination);
console.log(result);
}
exercise();12. Final Summary
JavaScript streams let you handle data progressively instead of waiting for everything at once. That makes them a strong fit for network responses, large files, and pipelines that need to process each chunk as it arrives.
The three main stream types each have a distinct role: readable streams produce data, writable streams consume data, and transform streams change data in transit. Once you understand locking, decoding, piping, and backpressure, you can build more efficient and responsive JavaScript code.
If you want to go further, the next useful topic is how fetch responses expose stream bodies and how to process text or binary chunks safely.