Streams, Piping, and Backpressure
Reading a 2 GB video with fs.readFile loads the whole file into memory before you can use a single byte. Streams let you process data piece by piece, as it arrives, using only a small amount of memory. Piping connects streams together, and backpressure stops a fast stream from flooding a slow one.
Definition
- Stream: an object that moves data in small pieces (chunks) over time, instead of all at once.
- Piping: connecting the output of one stream to the input of another, so data flows through automatically.
- Backpressure: a signal from a slow destination telling a fast source to pause until it catches up.
Analogy: think of moving water from a tank to a garden.
- Without streams: you fill one giant bucket with all the water, carry it over, and then pour. You need a bucket as big as the tank.
- With streams: you connect a pipe. Water flows a little at a time, and the garden gets it right away.
- Backpressure: if the garden can't soak it up fast enough, you close the tap a bit so it doesn't flood.
Why Streams?
const fs = require("fs");// Without streams: the whole 2 GB file is loaded into memoryfs.readFile("video.mp4", (err, data) => {// data is a 2 GB Buffer});// With streams: read in 64 KB chunksfs.createReadStream("video.mp4").on("data", (chunk) => {// chunk is about 64 KB});
readFile | Stream | |
|---|---|---|
| Memory used | The whole file | About one chunk at a time |
| First byte available | After the whole file is read | Immediately |
| Very large files | Can crash with out of memory | Works fine |
| Good for | Small files | Large files, videos, logs, uploads, HTTP |
The Four Types of Streams
| Type | What it does | Examples |
|---|---|---|
| Readable | You read data from it | fs.createReadStream, HTTP req (on a server), process.stdin |
| Writable | You write data to it | fs.createWriteStream, HTTP res (on a server), process.stdout |
| Duplex | Both read and write, independently | TCP socket (net.Socket) |
| Transform | A duplex that changes data as it passes through | zlib.createGzip(), crypto.createCipheriv() |
All streams are event emitters, so they report what happens through events.
Readable Streams
const fs = require("fs");const readable = fs.createReadStream("data.txt", { encoding: "utf8" });readable.on("data", (chunk) => console.log("Got", chunk.length, "characters"));readable.on("end", () => console.log("No more data"));readable.on("error", (err) => console.error("Failed:", err.message));
| Event | When it fires |
|---|---|
data | A new chunk is available |
end | There is no more data |
error | Something went wrong. Without a listener, the process crashes |
close | The stream and its resources are closed |
The Modern Way: for await...of
const fs = require("fs");async function countBytes(path) {let total = 0;for await (const chunk of fs.createReadStream(path)) {total += chunk.length;}return total;}
Readable streams are async iterable. The loop waits for each chunk, handles backpressure for you, and errors can be caught with try / catch.
Writable Streams
const fs = require("fs");const writable = fs.createWriteStream("output.txt");writable.write("Hello ");writable.write("World\n");writable.end("Done\n"); // write one last chunk and closewritable.on("finish", () => console.log("All data written"));writable.on("error", (err) => console.error("Failed:", err.message));
| Method / Event | What it does |
|---|---|
write(chunk) | Writes a chunk. Returns false when the internal buffer is full |
end([chunk]) | Writes an optional last chunk and closes the stream |
drain event | The buffer has emptied, so it's safe to write again |
finish event | All data has been written after end() |
Transform Streams
A transform stream receives chunks, changes them, and passes them on.
const { Transform } = require("stream");const toUpperCase = new Transform({transform(chunk, encoding, callback) {callback(null, chunk.toString().toUpperCase()); // (error, output)},});process.stdin.pipe(toUpperCase).pipe(process.stdout);// type "hello" -> prints "HELLO"
Piping
.pipe()
const fs = require("fs");const zlib = require("zlib");fs.createReadStream("logs.txt").pipe(zlib.createGzip()).pipe(fs.createWriteStream("logs.txt.gz"));
Data flows from the file, through gzip compression, into the new file, one chunk at a time. .pipe() returns the destination, so you can chain it.
The problem with .pipe():
- Errors are not forwarded. You need an
"error"listener on every stream. - If one stream fails, the others are not closed, which can leak file handles and memory.
pipeline(): The Recommended Way
const fs = require("fs");const zlib = require("zlib");const { pipeline } = require("stream/promises");async function compress(input, output) {try {await pipeline(fs.createReadStream(input),zlib.createGzip(),fs.createWriteStream(output),);console.log("Compressed");} catch (err) {console.error("Failed:", err.message);}}compress("logs.txt", "logs.txt.gz");
.pipe() | pipeline() | |
|---|---|---|
| Error handling | Add a listener on each stream | One try / catch for all of them |
| Cleanup on failure | Manual | Automatic: destroys every stream |
| Backpressure | Handled | Handled |
Works with async/await | No | Yes (stream/promises) |
Backpressure
The Problem
Reading from a disk is usually much faster than writing to a slow network or a slow disk. If the source keeps pushing data, the extra chunks pile up in memory.
// Wrong: ignores backpressureconst readable = fs.createReadStream("huge.log");const writable = fs.createWriteStream("copy.log");readable.on("data", (chunk) => {writable.write(chunk); // return value ignored});// If writing is slower than reading,// chunks pile up in memory until the process runs out of it
How Backpressure Works
Every stream has an internal buffer with a limit called highWaterMark (64 KB for file streams by default).
writable.write(chunk)returnstruewhile the buffer is below the limit.- When the buffer goes over the limit,
write()returnsfalse. This means: "stop sending for now". - The source should pause.
- When the buffer empties, the writable emits
drain. The source can resume.
Handling Backpressure Manually
// With eventsreadable.on("data", (chunk) => {const ok = writable.write(chunk);if (!ok) {readable.pause(); // stop readingwritable.once("drain", () => readable.resume()); // continue when ready}});readable.on("end", () => writable.end());
The Easy Way
You almost never need to do this by hand. pipe(), pipeline(), and for await...of all handle backpressure automatically.
// Backpressure handled for youawait pipeline(fs.createReadStream("huge.log"),fs.createWriteStream("copy.log"),);
Note:
highWaterMarkis a threshold, not a hard limit.write()still accepts data after returningfalse, which is exactly why ignoring it can fill up memory.
Streams in an HTTP Server
On a Node.js server, req is a Readable stream and res is a Writable stream.
const http = require("http");const fs = require("fs");const { pipeline } = require("stream/promises");http.createServer(async (req, res) => {if (req.url === "/video") {res.setHeader("Content-Type", "video/mp4");try {await pipeline(fs.createReadStream("movie.mp4"), res);} catch (err) {console.error("Stream failed:", err.message); // e.g. the client disconnected}return;}if (req.url === "/upload" && req.method === "POST") {try {await pipeline(req, fs.createWriteStream("upload.bin"));res.end("Uploaded");} catch (err) {res.statusCode = 500;res.end("Upload failed");}return;}res.statusCode = 404;res.end();}).listen(3000);
Each user uses only a few chunks of memory, so the server can stream a 2 GB video to many users at once. If a user's connection is slow, backpressure slows down reading the file for that user only.
Object Mode
By default, streams carry Buffers or strings. With objectMode: true, each chunk can be any JavaScript value, such as a database row.
const { Readable } = require("stream");const users = Readable.from([{ id: 1, name: "Asha" },{ id: 2, name: "Ravi" },]); // Readable.from() uses object mode by defaultfor await (const user of users) {console.log(user.name);}
In object mode, highWaterMark counts objects (default 16), not bytes.
Summary
- Streams process data in chunks, so memory stays small and data is available immediately.
- Four types: Readable, Writable, Duplex, and Transform.
- Readable events:
data,end,error. Readables are also async iterable withfor await...of. - Writable:
write(),end(), and thedrainandfinishevents. .pipe()connects streams, but doesn't forward errors or clean up.pipeline()fromstream/promiseshandles errors, cleanup, and backpressure. Prefer it.- Backpressure: when
write()returnsfalse, pause the source and resume ondrain. pipe(),pipeline(), andfor await...ofhandle backpressure automatically.- On a server,
reqis Readable andresis Writable.
Interview Answer: What Are Streams and Backpressure in Node.js?
A stream moves data in small chunks instead of loading everything into memory. This lets Node.js handle huge files with a small, constant amount of memory, and start working on the data immediately. There are four types: Readable (like fs.createReadStream or an HTTP request), Writable (like fs.createWriteStream or an HTTP response), Duplex (like a TCP socket), and Transform, which changes data as it passes, like zlib.createGzip().
Piping connects streams so data flows from one to the next. I prefer pipeline() from stream/promises over .pipe(), because it forwards errors to one try / catch and destroys all streams if one fails, so nothing leaks.
Backpressure happens when the source is faster than the destination. Each writable has a buffer limit called highWaterMark. When the buffer is full, write() returns false, and the source should pause until the writable emits drain. If I ignore that, chunks pile up in memory and can crash the process. pipe(), pipeline(), and for await...of all handle backpressure automatically, so I only manage it by hand when calling write() myself.