Why do streams need a reader to start flowing?
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.
The Confusion
Look at this code and ask yourself: does transformedStream contain the uppercase data?
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.
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.
// 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.
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.
Proof: Nothing Runs Until You Read
Here's a quick experiment that proves the transform function only runs when data is pulled:
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.
Ways to Consume a Stream
getReader() isn't the only way to start pulling data. Any consumer triggers the pipeline:
// 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.
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.
// 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
}
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.