This article is published in English.
Understanding Node.js Streams: The Problem They Actually Solve
Learn why Node.js streams exist, how piping works internally, and what backpressure really means for handling large data efficiently.
Most tutorials on streams open with the API surface, .pipe(), Readable, Writable, Transform, before ever touching the actual problem these tools were built to solve. Approaching it that way makes streams feel like arbitrary ceremony you have to memorize. Reverse the order, start from the problem, and the API mostly explains itself.
The Problem: Some Data Is Too Big to Hold at Once
Imagine a task that requires turning a 4GB CSV export into JSON. The naive approach looks like this:
const fs = require("fs");
const data = fs.readFileSync("export.csv", "utf8");
const rows = data.split("\n").map(parseRow);
fs.writeFileSync("export.json", JSON.stringify(rows));
readFileSync won't return control to the program until the entire 4GB file has been loaded into memory as a single JavaScript string, and that string representation typically consumes noticeably more memory than the raw file size would suggest. On a machine with only 2GB of available RAM, this code doesn't just run inefficiently, it crashes outright, or it starves every other process fighting for the same memory pool. There's nothing wrong with the logic itself; the transformation steps are all correct. The real flaw is a hidden assumption inside readFileSync: that it's fine to hold an entire file in memory simultaneously, no matter how large that file is.
The Actual Idea Behind a Stream
Instead of demanding the whole dataset upfront, a stream operates on a different premise: request one piece, work through it, then request the next piece. At no point does the complete file live in memory. Only a small slice exists at any given moment, and that slice gets processed and released before the next one shows up.
const fs = require("fs");
const readStream = fs.createReadStream("export.csv", { encoding: "utf8" });
readStream.on("data", (chunk) => {
console.log(`Received ${chunk.length} characters`);
});
readStream.on("end", () => {
console.log("Done reading the whole file, piece by piece");
});
Notice that the file's overall size never factored into this code. Whether the source file is 4GB or 4KB, this exact snippet runs identically, though the peak memory footprint differs enormously between the two cases. That's the whole point of streaming: you swap a requirement for the complete dataset before starting for the ability to begin instantly and finish the job without ever keeping more than a narrow window of data resident in memory.
Piping: Connecting a Source Directly to a Destination
Manually pulling chunks one at a time is a useful building block, but in practice it's far more common to wire a readable stream straight into a writable one, letting data flow from origin to destination automatically instead of relaying each chunk by hand:
const fs = require("fs");
fs.createReadStream("export.csv")
.pipe(fs.createWriteStream("export-copy.csv"));
Two lines are enough to duplicate a file of any size without loading the whole thing into memory. .pipe() isn't performing any magic here, it's simply routing the data event emitted by the source into a write call on the destination, plus handling one additional detail that turns out to matter more than the copying mechanism itself.
The Part Almost Nobody Explains Well: Backpressure
This is the real problem .pipe() addresses, beyond mere convenience. Picture a scenario where reading happens quickly, say from a local disk, while writing happens slowly, perhaps over a network connection with constrained bandwidth.
readStream.on("data", (chunk) => {
writeStream.write(chunk); // what happens if this can't keep up?
});
Calling .write() faster than the destination can actually absorb the data doesn't cause the writable stream to throw an error or block execution. Instead, it silently accumulates the surplus in an internal memory buffer, holding it until it gets a chance to flush. Keep this mismatch going long enough, with a wide enough gap between read speed and write speed, and the exact problem streaming was meant to eliminate gets quietly rebuilt: memory usage growing without bound, just postponed rather than immediate, and typically much harder to spot before it triggers a crash.
Backpressure is the safeguard against exactly this failure mode: it gives a writable stream a way to signal that it has reached capacity and needs the producer to ease off, and a properly implemented producer honors that signal instead of pushing through it.
readStream.on("data", (chunk) => {
const canContinue = writeStream.write(chunk);
if (!canContinue) {
readStream.pause(); // stop reading until the writable side catches up
}
});
writeStream.on("drain", () => {
readStream.resume(); // writable side is ready for more
});
The instant a writable stream's internal buffer exceeds its configured limit, .write() returns false, which serves as the cue to halt production until the stream fires a drain event indicating it has cleared the backlog and is ready to accept more. This pattern of pausing and resuming is precisely what .pipe() handles automatically behind the scenes:
readStream.pipe(writeStream); // handles backpressure for you, silently, correctly
This, not brevity, is the real justification for favoring .pipe() over manually wiring up data and write listeners. Forwarding chunks by hand while ignoring the return value of .write() reintroduces the same unbounded-memory failure that streams were designed to avoid in the first place, it's simply a step removed from the obvious mistake baked into readFileSync.
Transform Streams: Processing Data in Flight
There are cases where moving data untouched from one place to another isn't enough, you also need to reshape it along the way. A Transform stream is built for exactly this: it sits in the middle of a pipe chain, accepts incoming chunks, applies some operation to each one, and forwards the result to whatever comes next:
const { Transform } = require("stream");
const upperCaseTransform = new Transform({
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
fs.createReadStream("input.txt")
.pipe(upperCaseTransform)
.pipe(fs.createWriteStream("output.txt"));
Every chunk is converted to uppercase as it moves through the pipeline, and at no point does the complete file, in either its original or converted form, need to sit fully in memory. This is precisely the mechanism behind Node's built-in zlib.createGzip(). It's nothing more than a Transform stream that compresses each chunk as it arrives, and like any transform, it can be dropped straight into a pipe chain the same way the uppercase example was:
const zlib = require("zlib");
fs.createReadStream("export.csv")
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream("export.csv.gz"));
Reading, compressing, and writing all happen at once, working on small slices of data as they arrive, with none of the three stages ever needing the entire file loaded at the same time.
Why This Is Worth Actually Understanding
Streams have a reputation for being one of the more awkward corners of Node's API surface, and honestly the raw, event-driven interface does feel clunky, so that reputation isn't entirely undeserved. But the concept underneath it is simple: avoid loading the whole dataset into memory, work through it piece by piece instead, and make sure a fast producer can never quietly flood a slow consumer while doing so. Once you internalize that as the actual model, .pipe(), Transform, and backpressure stop feeling like three unrelated APIs to memorize separately. They're really a single idea, surfaced through three connected pieces of the API, solving a problem that something like readFileSync never had to face, because it was never meant to handle anything beyond small files to begin with.