Writable Streams
What is a Writable stream?
Section titled “What is a Writable stream?”A Writable is a stream to which data can be written. Node’s built-in examples include fs.createWriteStream (writing a file), process.stdout (terminal output), and HTTP response objects. You can also create custom writables by extending Writable and implementing the _write method.
write() and end()
Section titled “write() and end()”The two primary methods on a Writable are:
writable.write(chunk[, encoding][, callback])— writes a chunk of data. Returnstrueif the internal buffer is below the high-water mark,falseif the destination cannot keep up (backpressure signal).writable.end([chunk][, callback])— signals that no more data will be written. Optionally writes one final chunk. Triggers thefinishevent once all data has been flushed.
const fs = require('node:fs');
const dest = fs.createWriteStream('./output.txt');
dest.write('first line\n');dest.write('second line\n');dest.end('last line\n', function() { console.log('all data flushed');});The finish event
Section titled “The finish event”After end() is called and all pending data has been flushed to the underlying system, the stream emits finish. This is the correct moment to know writing is truly complete:
const fs = require('node:fs');const dest = fs.createWriteStream('./output.txt');
dest.on('finish', function() { console.log('write complete');});
dest.end('done');The drain event and flow control
Section titled “The drain event and flow control”write() returns a boolean. When it returns false, the internal buffer is full — you must stop writing and wait for the drain event before writing more. Ignoring this causes unbounded memory growth as buffered chunks accumulate.
function writeWithDrain(writable, chunks) { var i = 0;
function next() { while (i < chunks.length) { var ok = writable.write(chunks[i]); i++; if (!ok) { // buffer full — wait for drain before continuing writable.once('drain', next); return; } } writable.end(); }
next();}This is the manual pattern that pipe and pipeline handle for you automatically. Understanding it explains why those higher-level APIs exist.
Measuring highWaterMark, write(), and writableLength
Section titled “Measuring highWaterMark, write(), and writableLength”writable.writableLength reports exactly how many bytes (or objects, in object mode) are currently buffered and not yet flushed to the underlying destination. Watching it alongside write()’s return value makes the high-water mark concrete instead of abstract:
Output (verified on Node 24)
highWaterMark: 10
write() #1 returned: true | writableLength: 5
write() #2 returned: false | writableLength: 15
drain fired -- writableLength now: 0Notice writableLength reaches 15 — above the high-water mark of 10. The high-water mark is a threshold that flips write()’s return value to false, not a hard cap: Node still accepts and buffers the chunk you just wrote, and it becomes your responsibility to stop calling write() until drain fires.
Runnable demo
Section titled “Runnable demo”The demo creates a custom Writable that logs each chunk to the console, simulating a slow sink. It then writes three chunks and closes the stream.
Output (verified on Node 24)
sink received: alpha
sink received: beta
sink received: gamma
stream finishedMental Model
Section titled “Mental Model”flowchart TB Write["writable.write(chunk)"] -->|"below high-water mark"| True["returns true -- keep writing"] Write -->|"at or above high-water mark"| False["returns false -- buffer is full"] False -->|"you must"| Pause["stop writing"] Pause -->|"wait for"| Drain["'drain' event"] Drain -->|"safe to"| Write End["writable.end()"] -->|"once all buffered data is flushed"| Finish["'finish' event"]
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Manual write()/drain handling | Full control over exactly when and how much to buffer | Verbose and easy to get wrong — one missed check and memory grows without bound |
.pipe() / pipeline() | Handles the write()/drain dance for you automatically | Less direct control; you consume the higher-level API’s error/completion contract instead of rolling your own |
Checking writableLength directly | Gives an exact, real-time buffered-byte (or object) count for monitoring/logging | One more thing to read correctly — it means nothing without also knowing the current writableHighWaterMark |
Common Misconceptions
Section titled “Common Misconceptions”- “The high-water mark is a hard limit on how much writable can buffer” — fact: it’s a soft threshold that flips
write()’s return value tofalse; Node still accepts and buffers the chunk that pushed you over it. - “
write()returningfalsemeans the write failed” — fact: the chunk was still accepted and will still be flushed;falseis purely a signal to slow down, not an error. - “
finishandclosemean the same thing” — fact:finishfires once all buffered data has been flushed afterend();closefires when the underlying resource (a file descriptor, a socket) has actually been released, which can happen at a different time.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does code call
writable.write()in a loop without ever checking its boolean return value or listening fordrain? - Does a custom
Writable’swrite(chunk, encoding, callback)implementation forget to callcallback()on every code path (including error paths), which stalls the stream forever waiting for a callback that never comes? - Does code assume
end()completing means the destination file/socket is fully closed, when it should wait forfinish(data flushed) orclose(resource released) depending on what it actually needs to know?
🐛 Spot the bug
const { Writable } = require('node:stream');const fs = require('node:fs');
var dest = fs.createWriteStream('./output.log');
function writeAll(lines) {for (var i = 0; i < lines.length; i++) { dest.write(lines[i] + '\n'); // return value never checked}dest.end();}
writeAll(new Array(1000000).fill('a log line'));Show the answer
writeAll calls dest.write() in a tight loop without ever checking its return value or pausing when it returns false. If the destination (a disk, a network socket) cannot keep up with a million synchronous write() calls, Node keeps buffering every chunk in memory regardless, and the process can OOM before end() is even reached. The fix is the manual pattern from earlier in this lesson — check write()’s return value and pause via writable.once('drain', next) — or, more simply, replace the whole loop with a Readable wrapping lines piped into dest.