Web APIsJavaScriptMedium

Why do streams need a reader to start flowing?

01

The Short Answer

Calling .pipeThrough() does NOT execute any transformations. It only wires up a pipeline — a blueprint describing how data should flow. No data moves until a consumer (like getReader()) actively pulls it through.

02

The Confusion

Look at this code and ask yourself: does transformedStream contain the uppercase data?

the-question.tstypescript
const response = await fetch('/api/stream-text');

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

const transformedStream = response.body
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(upperCaseStream);

// Is the data already transformed here? NO!
// transformedStream is a ReadableStream — a tap that hasn't been turned on yet.

const reader = transformedStream.getReader(); // ← THIS starts things moving
const { value } = await reader.read();        // ← THIS pulls the first chunk through

The answer

transformedStream is not data. It's a ReadableStream — a description of where to get data and how to transform it. Think of it as a faucet, not a bucket. Nothing flows until you turn it on.

03

Streams Are Lazy

This is the key insight: streams are pull-based and lazy. When you call pipeThrough(), all you're doing is connecting pipes together. No data is read, no transformation runs, nothing happens.

pipeline-stages.tstypescript
// Stage 1: Wire up the pipeline (NO data flows yet)
const transformedStream = response.body         // raw bytes — waiting
  .pipeThrough(new TextDecoderStream())          // bytes → text — waiting
  .pipeThrough(upperCaseStream);                 // text → UPPERCASE — waiting

// At this point:
// ✗ No data has been read from the network
// ✗ No decoding has happened
// ✗ No uppercasing has happened
// The pipeline EXISTS but is IDLE

// Stage 2: Create a consumer
const reader = transformedStream.getReader();

// Stage 3: Pull data — NOW things happen
const { value, done } = await reader.read();
// ✓ A chunk is read from response.body
// ✓ That chunk is decoded to text
// ✓ That text is uppercased
// ✓ The result arrives in `value`

Each call to reader.read() pulls exactly one chunk through the entire pipeline. The pipeline processes data on-demand, chunk by chunk.

04

An Analogy

Think of it like a water filtration system:

Water pipe analogy

  • `pipeThrough()` = connecting pipes and filters together. No water flows yet.
  • `getReader()` = attaching a faucet at the end of the pipe.
  • `reader.read()` = opening the faucet. Water (data) now flows through all the filters.

Without opening the faucet, you just have a connected set of pipes sitting there doing nothing.

05

Proof: Nothing Runs Until You Read

Here's a quick experiment that proves the transform function only runs when data is pulled:

proof.tstypescript
const debugStream = new TransformStream({
  transform(chunk, controller) {
    console.log('Transform called with:', chunk); // When does this log?
    controller.enqueue(chunk.toUpperCase());
  }
});

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

// Connect the pipeline
const pipeline = source.pipeThrough(debugStream);
console.log('Pipeline connected');
// Output so far: "Pipeline connected"
// Notice: NO "Transform called" yet!

// Now consume it
const reader = pipeline.getReader();
await reader.read();
// Output: "Transform called with: hello"

await reader.read();
// Output: "Transform called with: world"

The proof

The console.log inside transform() only fires when reader.read() is called — not when pipeThrough() connects the streams. This confirms that pipeThrough() is purely setup, and read() is what triggers execution.

06

Ways to Consume a Stream

getReader() isn't the only way to start pulling data. Any consumer triggers the pipeline:

consumers.tstypescript
// Method 1: getReader() — manual chunk-by-chunk reading
const reader = transformedStream.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  process(value);
}

// Method 2: pipeTo() — pipe to a writable destination
await transformedStream.pipeTo(writableStream);

// Method 3: Response constructor — collect into a single result
const text = await new Response(transformedStream).text();

// Method 4: for-await-of (in environments that support it)
for await (const chunk of transformedStream) {
  process(chunk);
}

// All of these are consumers that trigger the pipeline to start flowing.
07

Why Lazy Streams Matter

Laziness isn't a quirk — it's a feature. It gives you powerful benefits:

Benefits of lazy evaluation

  • Memory efficiency — data is processed chunk-by-chunk, never all loaded at once
  • Backpressure — if the consumer is slow, the producer automatically slows down
  • Composability — you can build complex pipelines without triggering work until you're ready
  • Cancellation — if you stop reading, the entire pipeline stops. No wasted work.
lazy-benefit.tstypescript
// Because streams are lazy, you can set up a pipeline and pass it around
// without any data processing happening yet

function createProcessingPipeline(body: ReadableStream) {
  return body
    .pipeThrough(new TextDecoderStream())
    .pipeThrough(jsonParser)
    .pipeThrough(validator)
    .pipeThrough(formatter);
  // Zero work done here — just pipeline construction
}

// Later, the actual consumer decides when to start pulling
const pipeline = createProcessingPipeline(response.body!);

// Nothing has run yet. We can even add conditions:
if (userWantsData) {
  const reader = pipeline.getReader();
  // NOW the data starts flowing through all 4 stages
}
08

Key Takeaway

Quick Revision Cheat Sheet

pipeThrough(): Connects stages — defines HOW data should be transformed. No data moves.

getReader(): Creates a consumer — attaches a handle to pull data from the pipeline.

reader.read(): Actually pulls data — triggers transformations for one chunk at a time.

Lazy by design: Streams don't process data until a consumer actively requests it.

Pipeline ≠ Data: A ReadableStream is a description of where data comes from, not the data itself.