Writable Streams
A writable stream is an abstraction over a destination you can push data into incrementally — a file, a TCP socket, an HTTP response, or stdout. Instead of holding an entire payload in memory, you write it in chunks and let Node.js flush each chunk to the underlying resource as capacity allows. Understanding how write(), end(), and the flow-control events behave is essential to writing data efficiently without exhausting memory.
Creating a writable stream
Most writable streams come from core APIs rather than being constructed by hand. fs.createWriteStream() gives you a stream to a file, res in an HTTP handler is a writable stream, and process.stdout is one too. Each accepts chunks via write() and is closed via end().
import { createWriteStream } from "node:fs";
const out = createWriteStream("output.txt", { encoding: "utf8" });
out.write("First line\n");
out.write("Second line\n");
out.end("Last line\n");
The end() call signals that no more data will be written. You can optionally pass a final chunk to end(), which is equivalent to calling write() once more and then end() with no arguments.
Writing data with write()
write(chunk[, encoding][, callback]) queues a chunk to be flushed to the destination. The chunk is usually a string or a Buffer. The optional callback fires once that specific chunk has been handled.
import { createWriteStream } from "node:fs";
const log = createWriteStream("app.log", { flags: "a" });
log.write("startup complete\n", "utf8", () => {
console.log("chunk persisted to disk");
});
The
flags: "a"option opens the file in append mode so existing contents are preserved. Use the default"w"to truncate and overwrite.
The return value of write() and backpressure
write() returns a boolean. When it returns true, the internal buffer is below the highWaterMark and you may keep writing. When it returns false, the buffer is full — the consumer (disk, socket) cannot keep up, and you should stop writing until the stream drains. Ignoring this return value is the most common cause of runaway memory usage, because Node.js will happily buffer everything you throw at it in RAM.
write() returns | Meaning | What to do |
|---|---|---|
true | Buffer below highWaterMark | Continue writing |
false | Buffer full | Pause and wait for drain |
The drain event
When a stream that previously returned false has flushed enough of its buffer to accept more data, it emits a drain event. The correct backpressure-aware pattern is to write until write() returns false, then wait for drain before resuming.
import { createWriteStream } from "node:fs";
function writeOneMillionRows(file) {
const stream = createWriteStream(file);
let i = 0;
function write() {
let ok = true;
while (i < 1_000_000 && ok) {
const last = i === 999_999;
const line = `row ${i}\n`;
i++;
// For the final write, call end() instead of write().
if (last) {
stream.end(line);
} else {
ok = stream.write(line);
}
}
if (i < 1_000_000) {
// Buffer is full — wait for drain, then resume.
stream.once("drain", write);
}
}
write();
}
writeOneMillionRows("rows.txt");
Output:
(no console output; rows.txt is written without buffering all 1M rows in memory)
This loop never lets the in-memory buffer grow unbounded: as soon as write() returns false, it yields control and resumes only when drain fires.
The finish event
After you call end() and all buffered data has been flushed to the underlying system, the stream emits finish. This is the signal that the write is truly complete — not when end() returns, which only schedules the close.
import { createWriteStream } from "node:fs";
const file = createWriteStream("report.txt");
file.write("Quarterly report\n");
file.write("All systems nominal\n");
file.end();
file.on("finish", () => {
console.log("All data flushed to disk");
});
file.on("error", (err) => {
console.error("Write failed:", err.message);
});
Output:
All data flushed to disk
Always attach an error listener. Unhandled stream errors are thrown as uncaught exceptions and will crash the process.
CommonJS note
The same APIs work under CommonJS — swap the import for require:
const { createWriteStream } = require("node:fs");
const out = createWriteStream("output.txt");
out.end("done\n");
Key events and methods
| Member | Type | Purpose |
|---|---|---|
write(chunk) | method | Queue a chunk; returns false when buffer is full |
end([chunk]) | method | Finish writing, optionally with a final chunk |
drain | event | Buffer emptied enough to resume writing |
finish | event | All data flushed after end() |
error | event | An error occurred during writing |
Best Practices
- Always check the return value of
write()and pause when it isfalse; resume ondrain. - Call
end()exactly once — neverwrite()afterend(), which throws anERR_STREAM_WRITE_AFTER_END. - Attach an
errorlistener to every writable stream to avoid crashing on I/O failures. - Prefer
pipeline()orpipe()over manual loops when copying between streams — they handle backpressure and cleanup for you. - Tune
highWaterMarkonly when profiling shows a real need; the defaults (16 KB for files) are sensible. - Treat
finish, not the return ofend(), as the signal that data has actually reached the destination.