Using Web Streams Instead of Node Streams at the Edge
This guide is part of Supported Web APIs in Edge Runtimes. It shows how to rewrite Node stream code as Web Streams so a response body flows through your middleware chunk by chunk, without ever being fully materialised in isolate memory.
Streaming is the one area where the Node-to-edge port is not a cosmetic rename. The two stream systems have the same purpose but different ownership models, different backpressure semantics, and — crucially — different failure modes when you get it wrong. Node punishes a mistake with a warning; the edge punishes it with an out-of-memory kill halfway through a large response.
The problem
You lift a working transform out of a Node server and drop it into edge middleware. The build fails on Module not found: Can't resolve 'stream'. You reach for a polyfill, it bundles, and now the worker exceeds its size limit. You give up on streaming, call await response.text(), mutate the string, and return a fresh Response. Everything passes in staging.
Then someone requests a 40 MB export and the isolate dies with an out-of-memory error, or the request stalls until the platform’s wall-clock limit fires. Meanwhile Time to First Byte on every page has regressed from 90 ms to the full origin render time, because your middleware now waits for the last byte of the origin body before emitting the first byte of its own.
Root cause: no Node stream module, and a hard per-isolate memory ceiling
Edge runtimes implement the WHATWG Streams standard — ReadableStream, WritableStream, TransformStream, pipeTo, pipeThrough — and nothing from node:stream. There is no Readable, no .pipe(), no 'data' event, no Buffer. The Node stream module is not a thin API surface you can shim; it is roughly ten thousand lines of state machine built on Node’s event loop, and the polyfills that exist blow past the bundle budgets described in optimizing bundle size for edge runtime deployment.
The memory ceiling is the second half of the constraint. An isolate gets on the order of 128 MB, shared with your code, the runtime, and every concurrent request that isolate is serving. await response.text() on a 40 MB body allocates the bytes, then the decoded string — realistically 80 MB or more of peak resident memory for one request. Web Streams exist precisely so you never have to hold the whole body: a chunk arrives, you transform it, you enqueue it downstream, and the original chunk becomes garbage. Peak memory is one chunk, not one body.
Step 1 — Replace the Node transform with a TransformStream
A Node Transform implements _transform(chunk, encoding, callback) and pushes with this.push(). The Web Streams equivalent implements transform(chunk, controller) and pushes with controller.enqueue(). The controller replaces both this.push and the callback; returning from transform (or resolving the promise it returns) is the signal that the chunk is done.
// lib/inject-nonce.ts
// Replaces a Node Transform that rewrote a CSP nonce into streamed HTML.
export function createNonceInjector(nonce: string): TransformStream<Uint8Array, Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
// Carry-over holds the tail of a chunk that may contain a split token.
let carry = '';
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
const text = carry + decoder.decode(chunk, { stream: true });
// Keep the last 32 chars back in case the placeholder straddles a chunk.
const safeLength = Math.max(0, text.length - 32);
const emit = text.slice(0, safeLength);
carry = text.slice(safeLength);
if (emit) controller.enqueue(encoder.encode(emit.replaceAll('__NONCE__', nonce)));
},
flush(controller) {
// decode() with no argument flushes any pending multi-byte sequence.
const tail = carry + decoder.decode();
if (tail) controller.enqueue(encoder.encode(tail.replaceAll('__NONCE__', nonce)));
},
});
}
Two details matter here and neither is optional. The { stream: true } flag tells TextDecoder that more bytes are coming, so a UTF-8 sequence split across a chunk boundary is held rather than replaced with a substitution character. The carry buffer solves the parallel problem at the token level: a chunk boundary can land in the middle of __NONCE__, and a naive per-chunk replaceAll would miss it. Holding back a window slightly larger than your longest search token is the cheapest correct fix.
Step 2 — Pipe the response body through it
pipeThrough connects a readable to a transform and hands you back the transform’s readable side. Nothing is buffered by you, and nothing is awaited before the response is returned.
// middleware.ts
import { createNonceInjector } from './lib/inject-nonce';
export default async function middleware(request: Request): Promise<Response> {
const upstream = await fetch(request);
const contentType = upstream.headers.get('content-type') ?? '';
// Only transform HTML. Binary bodies must pass through untouched.
if (!contentType.includes('text/html') || !upstream.body) return upstream;
const nonce = crypto.randomUUID().replaceAll('-', '');
const body = upstream.body.pipeThrough(createNonceInjector(nonce));
const headers = new Headers(upstream.headers);
// The transform changes byte length, so any declared length is now a lie.
headers.delete('content-length');
headers.set('content-security-policy', `script-src 'nonce-${nonce}'`);
return new Response(body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
}
Deleting content-length is not a nicety. If you rewrite bytes and leave the upstream length header in place, the client will either truncate the body at the declared length or hang waiting for bytes that never arrive. Drop it and let the platform frame the response with chunked encoding. The same applies to content-encoding if the upstream body arrived compressed — either request an identity encoding or decompress before transforming, because injecting plaintext into a gzip stream produces a corrupt response.
Step 3 — Decode text chunks safely with TextDecoderStream
When your transform is genuinely text-oriented, you can let the platform handle the multi-byte boundary problem instead of hand-rolling it. TextDecoderStream is a TransformStream<Uint8Array, string> that maintains decoder state across chunks, and TextEncoderStream is its inverse.
// lib/redact.ts
export function redactStream(patterns: RegExp[]): ReadableWritablePair<Uint8Array, Uint8Array> {
const redactor = new TransformStream<string, string>({
transform(text, controller) {
let out = text;
for (const p of patterns) out = out.replace(p, '[redacted]');
controller.enqueue(out);
},
});
// Compose three stages into one pair: bytes -> text -> redacted text -> bytes.
const decoder = new TextDecoderStream();
const encoder = new TextEncoderStream();
decoder.readable.pipeThrough(redactor).pipeTo(encoder.writable).catch(() => {});
return { writable: decoder.writable, readable: encoder.readable };
}
The .catch(() => {}) on the internal pipe is deliberate. A pipeTo promise that rejects with no handler is an unhandled rejection, which some runtimes treat as a fatal error for the whole isolate. Errors still propagate to the caller through the stream itself; the catch only suppresses the duplicate.
Note that TextDecoderStream still does not solve token splitting — it guarantees valid characters, not valid tokens. If your transform searches for a multi-character string, you need the carry buffer from Step 1 regardless.
Step 4 — Let backpressure work instead of defeating it
Backpressure is the reason streaming keeps memory flat. Every readable has a queue with a high-water mark; when the consumer is slower than the producer, the queue fills, the producer’s pull stops being called, and — for a fetch body — the TCP receive window closes and the origin stops sending. The whole chain throttles itself to the speed of the slowest link.
pipeThrough and pipeTo implement this automatically. You lose it the moment you write a manual read loop that ignores what enqueue tells you, or that accumulates chunks in an array.
When you must write a manual loop — for example to inspect the first chunk before deciding whether to transform — respect the desiredSize signal and await the ready promise on the writer:
// lib/manual-pipe.ts
export async function copyWithBackpressure(
readable: ReadableStream<Uint8Array>,
writable: WritableStream<Uint8Array>,
): Promise<void> {
const reader = readable.getReader();
const writer = writable.getWriter();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
// Awaiting ready is what makes this loop honour backpressure.
await writer.ready;
await writer.write(value);
}
await writer.close();
} catch (err) {
await writer.abort(err);
throw err;
} finally {
reader.releaseLock();
}
}
Dropping the await writer.ready turns this into an unbounded queue: reads continue at origin speed while writes pile up in memory. That single missing line is the most common way a “streaming” implementation quietly becomes a buffering one.
Step 5 — Understand what tee() costs before you use it
tee() splits one readable into two. It is the obvious tool when you want to both return a body to the client and, say, hash it or log it. It is also the fastest way to reintroduce the memory problem you just solved.
The two branches share one underlying source, but each has its own queue. Chunks are held until both branches have read them. If one branch is a fast client download and the other is a slow analytics write, the queue for the slow branch grows to hold the entire difference — in the pathological case, the whole body. Worse, if you never read one branch at all, it holds every chunk forever and the transferred bytes are pinned in memory for the request’s lifetime.
The pass-through observer is usually what you actually wanted:
// lib/observe.ts
export function observeBody(
onDone: (stats: { bytes: number; chunks: number }) => void,
): TransformStream<Uint8Array, Uint8Array> {
let bytes = 0;
let chunks = 0;
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
bytes += chunk.byteLength;
chunks += 1;
controller.enqueue(chunk); // same reference, no copy
},
flush() {
onDone({ bytes, chunks });
},
});
}
When you genuinely need tee — for example to send a copy to an analytics endpoint — drain the second branch inside ctx.waitUntil and cancel it on any error, so a stalled consumer cannot pin the body. Reserve it for bodies you know are small.
Scoping the matcher
Streaming middleware should never run over responses it cannot usefully transform. Every static asset that passes through a transform pays a copy for nothing, and binary assets risk corruption if a content-type check is ever missed.
export const config = {
runtime: 'edge',
matcher: [
// HTML routes only: skip framework assets, images, fonts and media.
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|webp|avif|svg|woff2?|mp4)$).*)',
],
};
The negative lookahead does the coarse filtering; the content-type check in Step 2 is the fine filter that catches anything the path pattern lets through. Keep both — the matcher saves invocations, the content-type check preserves correctness.
Local versus production divergence
| Behaviour | Local dev (Node / Miniflare) | Edge production |
|---|---|---|
node:stream import |
Resolves under vitest with the Node pool |
Fails to bundle or throws at runtime |
| Chunk sizes | Often one large chunk from a local file | Many small chunks shaped by TCP segments |
| Multi-byte split across chunks | Rarely reproduced | Common on non-ASCII content |
| Memory ceiling | Your machine’s RAM | Roughly 128 MB per isolate, shared |
Unhandled pipeTo rejection |
Logged as a warning | Can terminate the isolate |
| Backpressure from the client | Loopback is effectively infinite bandwidth | Real, and paced by mobile links |
content-length mismatch |
Tolerated by many local servers | Truncates or hangs the response |
The chunk-size row is the reason most streaming bugs never appear locally. If your fixture arrives as a single chunk, every boundary-condition bug in your transform is invisible. Force small chunks in tests, deliberately.
Validating with Vitest
Build the fixture as a stream of deliberately awkward chunks — small, unevenly sized, and splitting both a multi-byte character and a search token across boundaries.
// stream.test.ts
import { describe, expect, it } from 'vitest';
import { createNonceInjector } from './lib/inject-nonce';
import { observeBody } from './lib/observe';
function chunkedStream(parts: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i >= parts.length) return controller.close();
controller.enqueue(encoder.encode(parts[i++]));
},
});
}
async function collect(stream: ReadableStream<Uint8Array>): Promise<string> {
const decoder = new TextDecoder();
const reader = stream.getReader();
let out = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
return out + decoder.decode();
}
describe('nonce injector', () => {
it('replaces a token that is split across two chunks', async () => {
const src = chunkedStream(['<script nonce="__NO', 'NCE__">x</script>']);
const out = await collect(src.pipeThrough(createNonceInjector('abc123')));
expect(out).toBe('<script nonce="abc123">x</script>');
});
it('does not corrupt a multi-byte character split across chunks', async () => {
const bytes = new TextEncoder().encode('café ☕');
const first = bytes.slice(0, 4); // splits the é
const second = bytes.slice(4);
const src = new ReadableStream<Uint8Array>({
start(c) { c.enqueue(first); c.enqueue(second); c.close(); },
});
expect(await collect(src.pipeThrough(createNonceInjector('n')))).toBe('café ☕');
});
it('streams incrementally rather than buffering the whole body', async () => {
const src = chunkedStream(['a', 'b', 'c', 'd']);
const reader = src.pipeThrough(observeBody(() => {})).getReader();
const first = await reader.read(); // resolves before the source closes
expect(new TextDecoder().decode(first.value)).toBe('a');
await reader.cancel();
});
it('reports byte and chunk totals from the observer', async () => {
let stats = { bytes: 0, chunks: 0 };
const src = chunkedStream(['one', 'two', 'three']);
await collect(src.pipeThrough(observeBody((s) => { stats = s; })));
expect(stats).toEqual({ bytes: 11, chunks: 3 });
});
});
The third test is the one worth keeping honest. It asserts that the first chunk is readable before the source has closed, which is the only mechanical difference between streaming and buffering. A text()-based implementation fails it.
Common pitfalls and resolutions
Module not found: Can't resolve 'stream' — a dependency imports node:stream. Find it with your bundler’s trace output and replace it, as covered in tree-shaking dependencies for edge middleware. A polyfill will bundle but will cost more than the feature is worth.
Garbled characters on non-English pages — decoding each chunk with a fresh TextDecoder or without { stream: true }. Reuse one decoder for the stream’s lifetime and flush it in flush().
Response truncated or client hangs — content-length left in place after a transform changed the byte length. Delete the header.
Memory climbs with response size despite “streaming” — a manual loop without await writer.ready, or an unread tee() branch. Restore backpressure or drop the tee.
Isolate terminated with no useful error — an unhandled rejection from pipeTo. Attach a catch to every detached pipe, even if it only swallows a duplicate error.
Corrupt output on compressed upstreams — text injected into a gzip body. Request identity encoding from the origin, or decompress with DecompressionStream before transforming.
Transform never runs — you built the TransformStream but returned upstream.body instead of the piped result. pipeThrough returns a new readable; you must use it.
Production deployment checklist
Frequently Asked Questions
Can I polyfill node:stream at the edge instead of rewriting?
You can bundle a polyfill, but it is a poor trade. The Node stream implementation is a large event-loop-bound state machine, and shims of it add tens of kilobytes to a bundle that has a hard size limit. Since edge runtimes already ship the full WHATWG Streams API natively, rewriting the handful of transforms you own is cheaper and faster than carrying the shim.
What actually happens if I ignore backpressure?
Reads keep arriving at origin speed while writes queue in isolate memory, so peak memory scales with the body size instead of the queue depth. On a large response that ends in an out-of-memory termination. Using pipeThrough and pipeTo gives you correct behaviour for free; a manual loop must await the writer ready promise before each write.
Why does my transform corrupt accented or emoji characters?
Because a UTF-8 character can span a chunk boundary and a fresh decoder per chunk sees an incomplete sequence. Create one TextDecoder for the stream’s lifetime, decode with the stream option set to true, and call decode with no argument in flush to emit anything still pending.
When is tee actually safe to use?
When both branches are drained at comparable speed and the body is small and bounded. The two branches share one source but keep separate queues, so a chunk stays resident until both have read it, and an unread branch pins the entire body. If you only need to hash or count the bytes, use a pass-through transform instead.
Do I have to delete content-length after transforming a body?
Yes, whenever the transform can change the byte length. Leaving the upstream value in place makes the client either truncate the body at the declared length or wait for bytes that never arrive. Delete the header and let the platform frame the response with chunked transfer encoding.
How do I test that my middleware really streams?
Feed it a readable that emits several small chunks, then assert that the first transformed chunk can be read before the source stream has closed. A buffering implementation cannot pass that assertion, because it has to consume the whole body before producing anything.