How does TransformStream work?
The Short Answer
TransformStream is a Web Streams API primitive that lets you intercept and modify data as it flows from a readable source to a writable destination. Think of it as a pipe fitting — data goes in one end, gets transformed in the middle, and comes out the other end changed.
The Mental Model
Imagine water flowing through a pipe. A TransformStream is a filter inserted into that pipe. Water (data) enters the writable side, passes through your transformation logic, and exits from the readable side. You define what happens in the middle.
A TransformStream has two sides
- ✅`writable` — the input side where data enters (a `WritableStream`)
- ✅`readable` — the output side where transformed data exits (a `ReadableStream`)
- ✅A `transformer` object in between — your custom logic that modifies each chunk
// The simplest TransformStream — uppercases text chunks
const upperCaseStream = new TransformStream({
transform(chunk: string, controller) {
// chunk = incoming data
// controller.enqueue() = push transformed data to the readable side
controller.enqueue(chunk.toUpperCase());
}
});
// It exposes two sides:
// upperCaseStream.writable → write data INTO this
// upperCaseStream.readable → read transformed data FROM this
How to Use It
The most common way to use a TransformStream is with .pipeThrough() — you pipe a readable stream through the transform, and get back a new readable stream with modified data.
// Example: Fetch a response and uppercase it as it streams in
const response = await fetch('/api/stream-text');
const upperCaseStream = new TransformStream({
transform(chunk, controller) {
// chunk is a string (after TextDecoderStream handles bytes → text)
controller.enqueue(chunk.toUpperCase());
}
});
// Chain: bytes → text → uppercase
const transformedStream = response.body!
.pipeThrough(new TextDecoderStream()) // bytes → text
.pipeThrough(upperCaseStream); // text → UPPERCASE TEXT
// Read the final result
const reader = transformedStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value); // "HELLO WORLD", "MORE DATA", etc.
}
The pipeline pattern
You can chain multiple .pipeThrough() calls to compose transformations: decode → transform → filter → format. Each stage is independent and reusable.
The Transformer Object
When creating a TransformStream, you pass a transformer object with three optional methods:
Transformer methods
- ✅`start(controller)` — called once when the stream is created. Use it to initialize state.
- ✅`transform(chunk, controller)` — called for every chunk of data. This is where you modify data and call `controller.enqueue()` to push it downstream.
- ✅`flush(controller)` — called once after all chunks have been written and the writable side is closed. Use it to emit any buffered/final data.
// A TransformStream that collects lines and emits them as JSON
const linesToJson = new TransformStream({
start(controller) {
// Initialize a buffer to collect lines
this.lines = [];
},
transform(chunk: string, controller) {
// Accumulate each chunk as a line
this.lines.push(chunk.trim());
// We don't enqueue here — we're buffering
},
flush(controller) {
// Stream is closing — emit the final result
controller.enqueue(JSON.stringify(this.lines));
}
});
Real-World Examples
Here are practical scenarios where TransformStream shines:
// Parse newline-delimited JSON (NDJSON) as it streams in
// Input: '{"name":"Alice"}\n{"name":"Bob"}\n'
// Output: { name: "Alice" }, { name: "Bob" } (as objects)
const ndjsonParser = new TransformStream({
start() {
this.buffer = '';
},
transform(chunk: string, controller) {
this.buffer += chunk;
const lines = this.buffer.split('\n');
// Keep the last incomplete line in the buffer
this.buffer = lines.pop()!;
// Parse and emit each complete line
for (const line of lines) {
if (line.trim()) {
controller.enqueue(JSON.parse(line));
}
}
},
flush(controller) {
// Don't forget the last line (no trailing newline)
if (this.buffer.trim()) {
controller.enqueue(JSON.parse(this.buffer));
}
}
});
// Usage with fetch
const response = await fetch('/api/events');
const events = response.body!
.pipeThrough(new TextDecoderStream())
.pipeThrough(ndjsonParser);
const reader = events.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value); // { name: "Alice" }, { name: "Bob" }, ...
}
// Track download progress without modifying data
function createProgressStream(totalBytes: number, onProgress: (pct: number) => void) {
let loaded = 0;
return new TransformStream({
transform(chunk: Uint8Array, controller) {
loaded += chunk.byteLength;
onProgress(Math.round((loaded / totalBytes) * 100));
// Pass chunk through untouched — we're just observing
controller.enqueue(chunk);
}
});
}
// Usage
const response = await fetch('/api/large-file');
const total = Number(response.headers.get('Content-Length'));
const tracked = response.body!.pipeThrough(
createProgressStream(total, (pct) => console.log(`${pct}% downloaded`))
);
// Filter out unwanted items from a stream
function createFilterStream<T>(predicate: (item: T) => boolean) {
return new TransformStream<T, T>({
transform(chunk, controller) {
// Only pass through items that match the predicate
if (predicate(chunk)) {
controller.enqueue(chunk);
}
// Items that don't match are silently dropped
}
});
}
// Usage: only keep events with type "error"
const errorEvents = eventStream.pipeThrough(
createFilterStream((event: Event) => event.type === 'error')
);
TransformStream vs Manual Piping
You could manually read from one stream and write to another, but TransformStream handles backpressure, error propagation, and cancellation for you automatically.
// ❌ Manual approach — you handle everything yourself
async function manualTransform(readable: ReadableStream<string>) {
const reader = readable.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value.toUpperCase()); // no backpressure handling!
}
return results;
}
// ✅ TransformStream — backpressure, errors, cancellation handled for you
const transformed = readable.pipeThrough(
new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
})
);
Backpressure explained
If the consumer reads slowly, TransformStream automatically slows down the producer. This prevents memory from exploding when processing large files or infinite streams. You get this for free.
Common Mistakes
Forgetting to call controller.enqueue()
If you don't call `controller.enqueue()` inside `transform()`, the data is silently dropped. The readable side will never see it.
✅Always call `controller.enqueue(transformedData)` to push data to the output. Only skip it intentionally when filtering.
Not handling the flush() for buffered transforms
If your transform buffers data (like collecting lines), forgetting `flush()` means the last chunk is lost when the stream ends.
✅Implement `flush(controller)` to emit any remaining buffered data before the stream closes.
Assuming chunks arrive in a specific size
Stream chunks can be split at arbitrary points — a word or JSON object might span two chunks. Never assume one chunk = one logical unit.
✅Buffer partial data and only emit complete units (full lines, complete JSON objects, etc.).
Why Interviewers Ask This
Streams are becoming essential in modern web development — from streaming LLM responses (ChatGPT-style token-by-token rendering) to processing large file uploads without blowing up memory. Understanding TransformStream shows you can build efficient, memory-conscious data pipelines that handle real-world scale. It also demonstrates knowledge of the Web Streams API, which is shared across browsers, Node.js, and Deno.
Quick Revision Cheat Sheet
TransformStream: A pipe fitting with a writable input side and a readable output side
transform(): Called for each chunk — modify and `controller.enqueue()` to pass downstream
flush(): Called once at the end — emit any buffered/final data
pipeThrough(): Chain a readable stream through a TransformStream
Backpressure: Automatically handled — slow consumers slow down producers
Use cases: NDJSON parsing, progress tracking, text encoding, data filtering, compression