JavaScript Streams API: ReadableStream, WritableStream, TransformStream

Link copied
JavaScript Streams API: ReadableStream, WritableStream, TransformStream

JavaScript Streams API: ReadableStream, WritableStream, TransformStream

JS Tutorial Module 10: Advanced Lesson 10.3

The Web Streams API is the modern, cross-platform way to handle chunks of data that arrive (or leave) over time. It's what powers response.body in fetch, what file inputs use to stream uploads, what Node.js streams interop with, and what AI APIs hand back for token-by-token completions.

This lesson covers the three core types — ReadableStream, WritableStream, TransformStream — the consumer patterns, async-iteration interop, and the practical idioms for piping data from one place to another.

What a stream is #

A stream is an abstraction over chunks of data that arrive (or are sent) over time, not all at once. Think "a file being downloaded" rather than "a file already in memory."

Three shapes:

  • ReadableStream — you consume from it (data flows out).
  • WritableStream — you push into it (data flows in).
  • TransformStream — a pair: data flows in, gets transformed, flows out.

Streams have built-in backpressure — if the consumer is slow, the producer pauses automatically. This is what makes them well-suited to networking and file I/O where rates are unpredictable.

ReadableStream #

The most-used type. Many APIs hand you one:

const res = await fetch('/api/users');
const stream = res.body;   // ReadableStream<Uint8Array>

Consuming via for await...of #

In modern browsers and Node 18+, ReadableStream is async-iterable:

for await (const chunk of stream) {
  console.log(chunk);  // Uint8Array of bytes
}

Combined with TextDecoderStream to convert bytes to strings:

for await (const text of stream.pipeThrough(new TextDecoderStream())) {
  console.log(text);  // string
}

Consuming via reader #

The lower-level API:

const reader = stream.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log(value);
}
reader.releaseLock();

Useful when you need fine-grained control (cancellation, manual buffering). For app code, for await...of is cleaner.

Cancellation #

stream.cancel('user navigated away');

Signals the source to stop producing. Used internally by fetch when an AbortSignal fires.

Creating a ReadableStream #

A stream you author:

const stream = new ReadableStream({
  start(controller) {
    controller.enqueue('hello ');
    controller.enqueue('world');
    controller.close();
  },
});

The controller methods: enqueue(chunk) adds a chunk, close() ends the stream, error(err) signals failure.

For async sources, use the pull(controller) method instead of start — it's called whenever the consumer is ready for more:

let i = 0;
const countingStream = new ReadableStream({
  async pull(controller) {
    if (i >= 10) {
      controller.close();
      return;
    }
    await new Promise(r => setTimeout(r, 100));
    controller.enqueue(i++);
  },
});

The stream produces 0, 1, 2… with 100ms gaps, but only as fast as the consumer asks for them.

From an async generator #

The modern shortcut — ReadableStream.from(asyncIterable):

async function* count() {
  for (let i = 0; i < 10; i++) {
    yield i;
    await new Promise(r => setTimeout(r, 100));
  }
}

const stream = ReadableStream.from(count());

No controller boilerplate. Any async generator becomes a stream.

WritableStream #

The sink side. You write data; it ends up somewhere (network, file, processing pipeline).

const writer = somewhere.getWriter();
await writer.write('hello');
await writer.write(' world');
await writer.close();
writer.releaseLock();

Creating one:

const saver = new WritableStream({
  write(chunk) {
    console.log('saving:', chunk);
    // could be async — return a Promise to apply backpressure
  },
  close() {
    console.log('done');
  },
  abort(reason) {
    console.log('aborted:', reason);
  },
});

const writer = saver.getWriter();
await writer.write('hi');
await writer.close();

WritableStreams are less common in application code than Readable ones — usually you'd write to a Response, a file, or pipe through a transform.

TransformStream #

A pair of streams — a writable side and a readable side, with a function in the middle.

const upper = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  },
});

await ReadableStream.from(['hello', 'world'])
  .pipeThrough(upper)
  .pipeTo(new WritableStream({
    write(chunk) { console.log(chunk); }  // 'HELLO', 'WORLD'
  }));

Built-in transforms you'll meet:

  • TextDecoderStream — bytes → strings (you'll use this with fetch bodies daily)
  • TextEncoderStream — strings → bytes
  • CompressionStream / DecompressionStream — gzip, deflate (browser-side compression!)
// Compress text on the fly
const compressed = ReadableStream.from(['hello world repeated lots of times'])
  .pipeThrough(new TextEncoderStream())
  .pipeThrough(new CompressionStream('gzip'));

const chunks = [];
for await (const chunk of compressed) chunks.push(chunk);
console.log('Total bytes:', chunks.reduce((s, c) => s + c.length, 0));

Piping #

Three pipe operations:

readable.pipeTo(writable);                 // connect read → write, returns a Promise
readable.pipeThrough(transform);           // returns a new readable (transform's readable side)
readable.tee();                            // splits into two identical readables

pipeTo #

The terminal connection. Returns a Promise that resolves when the data has flowed end-to-end:

const fileSink = new WritableStream({
  write(chunk) { /* save to disk */ },
});
await fetch('/api/big-export').then(res => res.body.pipeTo(fileSink));

Backpressure is handled automatically. The pipe takes care of the read-write coordination.

pipeThrough #

For chaining transforms:

await fetch('/api/data')
  .then(res =>
    res.body
      .pipeThrough(new TextDecoderStream())
      .pipeThrough(new TransformStream({
        transform(chunk, controller) {
          for (const line of chunk.split('\n')) controller.enqueue(line);
        },
      }))
      .pipeTo(new WritableStream({
        write(line) { console.log('line:', line); },
      }))
  );

Four stages in one pipeline: HTTP → bytes → strings → lines → log.

tee #

Split one stream into two:

const [a, b] = response.body.tee();
// Consume `a` for display, `b` to save to file simultaneously

Useful when you need to use the same data for two things (display AND cache, e.g.).

Backpressure in detail #

The core feature streams give you over raw async iteration: explicit, configurable backpressure.

Every readable has a highWaterMark — how many chunks can buffer before the producer is asked to pause. The default is 1 for byte streams, more for object streams.

const stream = new ReadableStream({
  pull(controller) { /* ... */ },
}, { highWaterMark: 10 });  // buffer up to 10 chunks

When the buffer is full, pull isn't called again until the consumer drains it. When you await writer.write(chunk) on a writable, the Promise only resolves once the writable has accepted the data — providing the same throttling.

For most code, you don't tune highWaterMark. Knowing it's there explains why streams "just work" under varying speeds.

When to use streams vs alternatives #

Need Use
Process a fetch body chunk by chunk response.body (ReadableStream)
Process an AI completion token stream ReadableStream + TextDecoderStream + line parser
Stream a file upload body: stream on fetch()
Read a Node file line by line createReadStream + readline (async-iterable)
Send a server response chunk by chunk new Response(stream)
Compress data in the browser CompressionStream
Process a queue with backpressure An async generator (Lesson 10.2), or a stream if interop matters

For pure in-process producer-consumer loops, async generators are usually simpler. For anything that crosses runtime boundaries (network, file, worker), Streams are the lingua franca.

Browser vs Node interop #

The Web Streams API is now the shared standard between browser and Node 18+. Same ReadableStream, same methods, same piping. Older Node has its own stream module (Readable, Writable, Duplex) — that's a separate, older API. Modern code uses Web Streams everywhere; Node provides adapters (Readable.fromWeb(), Readable.toWeb()) where needed.

A summary #

  • Three types: ReadableStream (consume), WritableStream (sink), TransformStream (in→transform→out).
  • for await of is the easiest way to consume a Readable.
  • pipeTo terminates a pipeline. pipeThrough chains transforms.
  • tee() splits a stream so two consumers can share it.
  • Backpressure is built-in via highWaterMark and the promise-based read/write.
  • ReadableStream.from(asyncIterable) is the shortcut to create a stream from a generator.
  • Web Streams are now standard in both browser and Node 18+.

What's next #

Lesson 10.4 covers Web Workers — running JavaScript on a separate thread, the postMessage interface, structured cloning, and when offloading work to a worker actually helps.

Try it yourself #

The pipeThrough chain is the easiest way to feel the composition power. Predict the output:

YouPredict the output:
const reverse = new TransformStream({
transform(chunk, controller) { controller.enqueue([...chunk].reverse().join('')); }
});
const upper = new TransformStream({
transform(chunk, controller) { controller.enqueue(chunk.toUpperCase()); }
});

const out = [];
await ReadableStream.from(['hello', 'world'])
.pipeThrough(reverse)
.pipeThrough(upper)
.pipeTo(new WritableStream({ write(c) { out.push(c); } }));
console.log(out);
Claude · used js_sandboxOutput: ['OLLEH', 'DLROW'].

Each chunk flowed 'hello' → 'olleh' → 'OLLEH'. Each TransformStream applied its function to each chunk, in order. pipeTo awaits the whole pipeline to finish.

That’s the entire mental model: chunks flow left to right through transforms, and the writable at the end collects the results.

Once streams compose this cleanly, plumbing data through your code stops being the bottleneck.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *