Readable and Writable Streams
Streams are one of Node.js’s core building blocks for working with data that arrives piece by piece instead of all at once — files, network requests, process input, and compressed archives all flow through streams. Instead of waiting for an entire file or response to load into memory, a stream lets you process each chunk as soon as it arrives, keeping memory use flat and letting your program start working before the source has even finished sending data. Node.js models the two directions of this flow as Readable streams (data flowing out of a source, into your code) and Writable streams (data flowing from your code into a destination). Understanding how they buffer data, emit events, and apply backpressure is essential for writing Node programs that stay fast and don’t fall over under load.
Overview: How Streams Work
Every stream in Node.js is built on top of EventEmitter. A Readable stream emits a data event each time a new chunk is available, and an end event once there is no more data. A Writable stream exposes a write() method to push data out and emits a finish event once everything has been flushed to the underlying resource (a file, a socket, a compression algorithm). Both directions emit an error event when something goes wrong.
Internally, every stream keeps an internal buffer whose size is controlled by the highWaterMark option (64 KB by default for non-object streams). For a Readable stream, this buffer fills up from the underlying source (disk, socket, another process) and drains as your code consumes it. For a Writable stream, it fills up as your code calls write() and drains as the underlying resource accepts the data. Chunks are delivered as Buffer objects by default — raw bytes — unless you set an encoding option, in which case Node decodes each chunk into a string (usually utf8) before handing it to you.
A Readable stream has two reading modes. In flowing mode, data is pushed to you automatically as soon as it’s available — this happens once you attach a data listener or call .pipe(). In paused mode, you must explicitly call .read() to pull a chunk; this is the default state and gives you manual control over pacing. Most application code uses flowing mode via .pipe() or an async iterator (for await (const chunk of readable)), letting Node handle the pacing for you.
Node.js actually has four stream base classes: Readable, Writable, Duplex (both readable and writable, like a TCP socket), and Transform (a duplex stream that modifies data as it passes through, like zlib.createGzip()). This lesson focuses on plain Readable and Writable streams; Transform streams get their own lesson later in this section.
Syntax
The most common way to get a stream is through a built-in factory function rather than constructing the class directly:
const readable = fs.createReadStream(path, options);
const writable = fs.createWriteStream(path, options);
| Option | Applies to | Meaning |
|---|---|---|
encoding |
both | Decode/encode chunks as strings (e.g. "utf8") instead of raw Buffers |
highWaterMark |
both | Internal buffer size in bytes before backpressure kicks in (default 64 KB) |
flags |
writable | File system flags, e.g. "a" to append instead of overwrite |
start / end |
readable | Byte offsets to read only part of a file |
The events you’ll use most often:
| Event | Stream type | Fires when |
|---|---|---|
data |
Readable | A new chunk is available (switches the stream to flowing mode) |
end |
Readable | No more data will ever be emitted |
drain |
Writable | The internal buffer has emptied enough to accept more writes |
finish |
Writable | All data has been flushed after calling .end() |
error |
both | Something failed — always listen for this |
close |
both | The underlying resource (file descriptor, socket) has been released |
Examples
Example 1: Reading a file with a Readable stream
const fs = require("node:fs");
const readStream = fs.createReadStream("report.csv", {
encoding: "utf8",
highWaterMark: 64 * 1024,
});
readStream.on("data", (chunk) => {
console.log(`Received ${chunk.length} characters`);
});
readStream.on("end", () => {
console.log("Finished reading the file");
});
readStream.on("error", (err) => {
console.error("Read error:", err.message);
});
Received 65536 characters
Received 12045 characters
Finished reading the file
Attaching a data listener switches the stream into flowing mode. Node reads the file in chunks up to highWaterMark bytes at a time, decodes each chunk to a UTF-8 string because encoding was set, and emits it. The exact number and size of chunks depends on the file size and the OS’s I/O behaviour — never assume a file arrives as a single chunk.
Example 2: Writing data with a Writable stream
const fs = require("node:fs");
const writeStream = fs.createWriteStream("output.txt");
writeStream.on("error", (err) => {
console.error("Write error:", err.message);
});
writeStream.on("finish", () => {
console.log("All data has been written");
});
writeStream.write("First line\n");
writeStream.write("Second line\n");
writeStream.end("Last line\n");
All data has been written
Each call to write() queues data to be flushed to disk; end() writes one final chunk and signals that no more writes will happen. The finish event fires only after every queued chunk has actually been flushed to the underlying file descriptor, not as soon as end() is called.
Example 3: Piping a Readable into a Writable (with compression)
const { pipeline } = require("node:stream/promises");
const fs = require("node:fs");
const zlib = require("node:zlib");
async function copyAndCompress(source, destination) {
await pipeline(
fs.createReadStream(source),
zlib.createGzip(),
fs.createWriteStream(destination)
);
console.log(`Compressed ${source} into ${destination}`);
}
copyAndCompress("report.csv", "report.csv.gz").catch((err) => {
console.error("Pipeline failed:", err.message);
});
Compressed report.csv into report.csv.gz
This chains a Readable, a Transform (zlib.createGzip()), and a Writable into one pipeline. pipeline() from node:stream/promises connects them, forwards backpressure through the whole chain automatically, and — critically — propagates an error from any stage to a single rejected promise, which is exactly what plain .pipe() chains fail to do.
Example 4: Building a custom Readable stream
const { Readable } = require("node:stream");
class NumberStream extends Readable {
constructor(max, options) {
super(options);
this.current = 1;
this.max = max;
}
_read() {
if (this.current > this.max) {
this.push(null);
return;
}
this.push(`${this.current}\n`);
this.current++;
}
}
const numbers = new NumberStream(5);
numbers.on("data", (chunk) => process.stdout.write(chunk.toString()));
numbers.on("end", () => console.log("Done generating numbers"));
1
2
3
4
5
Done generating numbers
Subclassing Readable means implementing _read(), which Node calls whenever it needs more data. You supply data by calling this.push(chunk); calling this.push(null) signals the end of the stream, which triggers the end event once all pushed data has been consumed. This pattern is how you’d stream data generated on the fly — from a database cursor, a generator function, or an API paginator — without loading it all into an array first.
Under the Hood: Backpressure and Buffering
Backpressure is what keeps a fast producer from overwhelming a slow consumer. When you call writeStream.write(chunk), it returns a boolean: true means the internal buffer is still below highWaterMark and you can keep writing; false means the buffer is full and you should stop until the stream emits a drain event. Ignoring this return value and writing as fast as possible defeats the entire purpose of streaming — the internal buffer grows unbounded and you’re back to holding everything in memory.
const fs = require("node:fs");
function writeNumbers(writeStream, total, current = 0) {
let ok = true;
while (current < total && ok) {
current++;
const line = `line ${current}\n`;
if (current === total) {
writeStream.write(line, () => console.log("Finished writing"));
} else {
ok = writeStream.write(line);
}
}
if (current < total) {
writeStream.once("drain", () => writeNumbers(writeStream, total, current));
}
}
const out = fs.createWriteStream("numbers.txt");
writeNumbers(out, 500000);
Finished writing
Step by step: the loop writes as long as write() keeps returning true. The moment it returns false, the loop stops and registers a one-time drain listener instead of continuing to buffer more data in memory; when the underlying file catches up, drain fires and the function resumes exactly where it left off. This is precisely what .pipe() and pipeline() do internally on your behalf, which is why piping is almost always preferable to writing this loop by hand.
Common Mistakes
Mistake 1: Loading a large file into memory instead of streaming it
const fs = require("node:fs");
const data = fs.readFileSync("huge-video.mp4");
fs.writeFileSync("copy.mp4", data);
This reads the entire file into a single Buffer in memory before writing any of it back out. For a multi-gigabyte file this can exhaust available memory, and readFileSync/writeFileSync both block the event loop for the entire operation — no other request can be handled in the meantime. Stream it instead:
const fs = require("node:fs");
const { pipeline } = require("node:stream/promises");
async function copyLargeFile(source, destination) {
await pipeline(fs.createReadStream(source), fs.createWriteStream(destination));
console.log("Copy complete without loading the whole file into memory");
}
copyLargeFile("huge-video.mp4", "copy.mp4").catch((err) => {
console.error("Copy failed:", err.message);
});
Now the file moves through in 64 KB chunks, memory usage stays flat regardless of file size, and the event loop is never blocked because the reads and writes are asynchronous.
Mistake 2: Forgetting to handle the error event
const fs = require("node:fs");
const readStream = fs.createReadStream("maybe-missing.txt");
readStream.pipe(fs.createWriteStream("copy.txt"));
If maybe-missing.txt doesn’t exist, the read stream emits an error event. Because streams are EventEmitters, an error event with no listener attached throws and crashes the entire Node process — not just this operation. Always attach error handlers on every stream involved:
const fs = require("node:fs");
const readStream = fs.createReadStream("maybe-missing.txt");
const writeStream = fs.createWriteStream("copy.txt");
readStream.on("error", (err) => {
console.error("Read failed:", err.message);
});
writeStream.on("error", (err) => {
console.error("Write failed:", err.message);
});
readStream.pipe(writeStream);
Better still, prefer pipeline() over raw .pipe() chains: it attaches error handling and cleanup (destroying every stream in the chain) automatically, so a failure partway through can’t leave a dangling open file descriptor.
Best Practices
- Prefer
pipeline()fromnode:stream/promisesover manual.pipe()chains — it forwards errors and cleans up every stream automatically. - Always attach an
errorlistener to every stream you create, or route it throughpipeline()‘s error handling. - Respect the return value of
write(); pause onfalseand resume ondraininstead of buffering more data yourself. - Use
for await (const chunk of readable)for simple sequential consumption when you don’t need the full power ofpipe(). - Set
highWaterMarkdeliberately for very large or very small chunk workloads instead of relying on the 64 KB default. - Never use synchronous
fscalls (readFileSync,writeFileSync) inside request handlers — they block the entire event loop. - Call
.destroy(err)on a stream you’re abandoning early to release its underlying file descriptor or socket. - Use
encoding: "utf8"only when you actually want strings; leave chunks as rawBuffers when passing them to another binary-aware stream likezlib.
Practice Exercises
- Write a script that uses
fs.createReadStreamand adatalistener to count the total number of lines in a text file without ever holding the whole file in memory at once. (Hint: count newline characters per chunk.) - Build a custom
Writablestream (subclassWritableand implement_write(chunk, encoding, callback)) that uppercases every chunk it receives and prints it to the console. - Use
pipeline()to chainfs.createReadStream,zlib.createGunzip(), andfs.createWriteStreamto decompress a.gzfile, then verify the pipeline correctly reports an error when you point it at a source file that doesn’t exist.
Summary
- A
Readablestream emits data out of a source in chunks; aWritablestream accepts data into a destination in chunks. - Both are built on
EventEmitter: key events aredata,end,drain,finish, anderror. - An internal buffer sized by
highWaterMarkcontrols how much data is held in memory before backpressure applies. write()returnsfalsewhen the buffer is full — stop writing and wait fordraininstead of ignoring it.- Prefer
pipeline()over manual.pipe()chains for correct error propagation and cleanup. - Streaming keeps memory flat and avoids blocking the event loop, which is why it beats
readFileSync/writeFileSyncfor large data. - You can build custom streams by subclassing
Readable(implement_read) orWritable(implement_write).
