Node.js and Express course menu
Node.js Streams: Process Large Files Efficiently
Understand Node.js streams: readable, writable and transform streams, reading big files in chunks, processing files line by line with readline, pipeline for copying and compressing with gzip, async iteration, and why streams keep memory use low.
On this page
readFile loads a whole file into memory at once. That is fine for small files, but a 5 GB log file would exhaust the memory. Streams process data piece by piece (in chunks) as it arrives, so memory use stays small no matter how big the data is. HTTP requests and responses, files, compression and network sockets are all streams in Node.
There are four kinds:
- Readable: a source you read from (a file, an incoming request);
- Writable: a destination you write to (a file, a response);
- Duplex: both (a network socket);
- Transform: a duplex stream that changes data passing through (compression, encryption).
Create a large file to work with#
import { createWriteStream } from "node:fs";
const out = createWriteStream("access.log");
const pages = ["/", "/products", "/cart", "/checkout"];
for (let i = 1; i <= 50000; i++) {
const status = i % 50 === 0 ? 500 : i % 9 === 0 ? 404 : 200;
out.write(`2026-10-25T10:00:00Z GET ${pages[i % pages.length]} ${status}\n`);
}
out.end(() => console.log("Wrote access.log"));node make-log.jsWrote access.logout.write adds data to the stream's buffer, and out.end() finishes the file; the callback runs when everything has been written.
Reading in chunks#
import { createReadStream } from "node:fs";
const stream = createReadStream("access.log", { encoding: "utf8" });
let chunks = 0;
let characters = 0;
for await (const chunk of stream) {
chunks += 1;
characters += chunk.length;
}
console.log(`Read ${characters} characters in ${chunks} chunks`);node chunks.jsRead 1800000 characters in 28 chunksA file stream reads 64 KiB at a time by default. for await...of reads each chunk as it arrives, so only one chunk needs to be in memory at a time.
Line by line with readline#
Chunks can split a line in half. readline reassembles the stream into lines, ideal for logs and CSV files:
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const lines = createInterface({ input: createReadStream("access.log"), crlfDelay: Infinity });
const counts = {};
for await (const line of lines) {
const status = line.split(" ").at(-1);
counts[status] = (counts[status] ?? 0) + 1;
}
console.log(counts);node errors.js{ '200': 43556, '404': 5444, '500': 1000 }This works the same for a file of a hundred lines or a hundred million, with flat memory use.
pipeline: connecting streams#
pipeline connects a readable stream through transforms into a writable stream, handles backpressure (slowing the reader when the writer cannot keep up), and cleans up all streams if any of them fails. Compressing a file with gzip:
import { createReadStream, createWriteStream } from "node:fs";
import { stat } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(createReadStream("access.log"), createGzip(), createWriteStream("access.log.gz"));
const before = (await stat("access.log")).size;
const after = (await stat("access.log.gz")).size;
console.log(`Compressed ${before} bytes to ${after} bytes (${((after / before) * 100).toFixed(1)}%)`);node compress.jsCompressed 1800000 bytes to 15941 bytes (0.9%)Log files are very repetitive, so they compress extremely well.
A custom transform stream#
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
const toUpper = new Transform({
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
await pipeline(Readable.from(["namaste ", "from ", "a stream\n"]), toUpper, process.stdout);node upper.jsNAMASTE FROM A STREAMReadable.from turns an array (or any iterable) into a readable stream, and process.stdout is a writable stream, so the result goes to the terminal.
Streams in servers#
HTTP responses are writable streams, so a server can send a large file without loading it into memory:
import { createReadStream } from "node:fs";
import { createServer } from "node:http";
createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
createReadStream("access.log").pipe(res);
}).listen(3000);Express's res.sendFile and express.static stream files for you in the same way.
Common mistakes#
Practice#
Practice exercise
Using the access.log file created above, count the requests per page (/, /products, /cart, /checkout) with readline, and print the counts sorted by page name.
Show hint
The page is the third space-separated field: line.split(" ")[2]. Sort with Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).
Show solution
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const counts = {};
for await (const line of createInterface({ input: createReadStream("access.log"), crlfDelay: Infinity })) {
const page = line.split(" ")[2];
counts[page] = (counts[page] ?? 0) + 1;
}
for (const [page, n] of Object.entries(counts).sort(([a], [b]) => a.localeCompare(b))) {
console.log(page.padEnd(10), n);
}node pages.js/ 12500
/cart 12500
/checkout 12500
/products 12500Key takeaway#
Finished this lesson?