What are streams in Node.js and why would you use them for large files?
A stream processes data in small chunks as it arrives instead of loading everything into memory at once. For a large file, streams keep memory use low and let you start sending data immediately, and pipeline() handles backpressure and errors for you.
Explain it simply
Think about watching a cricket match on a streaming app. You do not download the whole 8-hour match before pressing play. The video arrives in small pieces, and each piece is shown as soon as it lands. That is a stream.
In Node.js, a stream lets you read or write data piece by piece. Without streams, reading a 2 GB log file with fs.readFile puts all 2 GB in memory before you can do anything. With a stream, Node reads it in chunks of 64 KB (the default for file streams), and you handle each chunk and then let it go. Memory stays small no matter how big the file is.
Example
Sending a big file to the browser, the wrong way and the right way:
const http = require("node:http");
const fs = require("node:fs");
const { pipeline } = require("node:stream/promises");
http.createServer(async (req, res) => {
// Bad for big files: loads the whole file into memory first.
// const data = await fs.promises.readFile("./big-report.csv");
// res.end(data);
// Good: sends the file chunk by chunk.
res.setHeader("Content-Type", "text/csv");
try {
await pipeline(fs.createReadStream("./big-report.csv"), res);
} catch (err) {
console.error("Download failed:", err.message);
}
}).listen(3000);Compressing a file while reading it:
const fs = require("node:fs");
const zlib = require("node:zlib");
const { pipeline } = require("node:stream/promises");
async function compressLog() {
await pipeline(
fs.createReadStream("./orders.log"),
zlib.createGzip(),
fs.createWriteStream("./orders.log.gz")
);
}
compressLog().catch((err) => console.error(err));How it works
There are four kinds of streams:
- Readable: you read from it (
fs.createReadStream, an incoming HTTP request). - Writable: you write to it (
fs.createWriteStream, an HTTP response). - Duplex: both readable and writable (a TCP socket).
- Transform: a duplex stream that changes data as it passes (
zlib.createGzip).
An important idea is backpressure. If the reader is fast (a local disk) and the writer is slow (a user on a weak mobile network), data would pile up in memory. When a writable stream's buffer is full, write() returns false, and a well-behaved source pauses until the drain event. pipe() and pipeline() do this automatically.
Real-world use
- Serving video, PDFs and CSV exports.
- Uploading files to cloud storage without saving them to disk first.
- Processing large log or CSV files line by line (
readlineover a read stream). - Gzip compression of HTTP responses.
Common mistakes
- Using
readFilefor files that can be hundreds of MB. It works on a laptop and crashes in production. - Using
.pipe()chains without error handling. If one stream fails, the others are not cleaned up. Preferpipeline(). - Ignoring the return value of
write()in a loop, which removes backpressure and fills memory.
What to say in the interview
"A stream processes data in chunks instead of buffering the whole thing. There are readable, writable, duplex and transform streams. For a large file I would use fs.createReadStream and pipeline it into the response or into a transform like gzip, so memory stays roughly constant whatever the file size, and the user starts receiving data right away. pipeline also handles backpressure, pausing the source when the destination is slow, and it destroys all streams and reports the error if any step fails."
Follow-up questions
- What is backpressure and how does Node handle it?
- Why is
pipeline()preferred over.pipe()? - How would you read a large CSV file line by line?
Go deeper
Read these once the basics above feel easy.
Readable streams are async iterables, so you can write for await (const chunk of stream) { ... }. This is often the simplest way to process data, and it respects backpressure because the loop only asks for the next chunk after the current one is handled. For text, set an encoding (createReadStream(path, { encoding: "utf8" })) or use readline so multi-byte characters are not split across chunks.