Piping a Readable through a Transform stream with backpressure
Published August 14, 2026
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
const upperCaseChunks = new Transform({
highWaterMark: 16 * 1024, // downstream buffer fills before upstream is paused
transform(chunk: Buffer, _encoding, callback) {
callback(null, chunk.toString("utf8").toUpperCase());
},
});
export const upperCaseFile = async (sourcePath: string, destPath: string) => {
const source = createReadStream(sourcePath, { highWaterMark: 64 * 1024 });
const destination = createWriteStream(destPath);
await pipeline(source, upperCaseChunks, destination);
};What
upperCaseFile reads a file, uppercases it chunk by chunk through a Transform stream, and writes the result — using pipeline() so the whole chain respects backpressure automatically instead of buffering the file in memory.
Why it matters
readable.on("data", chunk => ...) reads as fast as the source can produce bytes, regardless of whether whatever's downstream can keep up. Point that at a slow network write or a CPU-heavy transform and Node happily buffers the entire backlog in memory — for a large file, that's the process getting OOM-killed for something a stream was supposed to make cheap.
The fix isn't a bigger buffer, it's respecting the signal each stream already gives you: a Writable's write() returns false when its internal buffer is full, and it emits 'drain' when it's ready for more. Transform streams (which are Duplex under the hood) propagate that signal both ways — they won't pull more from upstream until their own downstream has room. pipeline() from node:stream/promises wires all of that up correctly, plus destroys every stream in the chain and forwards the first error if any stage fails.
How it works
upperCaseChunksextendsTransformwith atransform()method that receives each chunk, uppercases it, and callscallback(null, result)— the callback is what actually signals "I'm done with this chunk," which is the internal mechanism that makes backpressure work.highWaterMark: 16 * 1024caps how much transformed output can sit in the Transform's internal buffer before it stops accepting new input from upstream — deliberately small here to make the effect visible on a large file.pipeline(source, upperCaseChunks, destination)connects all three streams and returns a promise that resolves when the last stream finishes and rejects if any stream errors — no manual'error'listeners on each stream needed.- Because
pipeline()is awaited,upperCaseFileitself only resolves once every byte has actually landed indestPath, not just once it's been read fromsourcePath.
Gotchas
- Never call
.pipe()manually and skip error handling "for now" — an unhandled'error'on any stream in an unpiloted chain crashes the process, and the source stream never gets.destroy()ed if the destination errors.pipeline()exists specifically to close that gap. Transform'scallbackaccepts a second argument for transformed output or you can callthis.push()from insidetransform()— mixing both for the same chunk double-emits it.- A
Transformthat does synchronous CPU-heavy work intransform()still blocks the event loop like any other synchronous code; backpressure controls memory, not concurrency.
Related
stream.Readable.from()— the other common stream entry point, for turning a plain (async) iterable into aReadableinstead of a file.events.once(stream, "finish")— a lower-level way to await stream completion when you need more control thanpipeline()gives you.