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.

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:

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

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

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

11. Practice Exercise

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.