Web APIsJavaScriptMedium

How does TransformStream work?

01

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.

02

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
basic-structure.tstypescript
// 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
03

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.

pipe-through-example.tstypescript
// 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.

04

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.
transformer-lifecycle.tstypescript
// 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));
  }
});
05

Real-World Examples

Here are practical scenarios where TransformStream shines:

json-line-parser.tstypescript
// 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" }, ...
}
progress-tracker.tstypescript
// 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-stream.tstypescript
// 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')
);
06

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-vs-transform.tstypescript
// ❌ 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.

07

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.).

08

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