Skip to content

Writable Streams

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.

The two primary methods on a Writable are:

  • writable.write(chunk[, encoding][, callback]) — writes a chunk of data. Returns true if the internal buffer is below the high-water mark, false if 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 the finish event 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');
});

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');

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:

Node.js
Output (verified on Node 24)
highWaterMark: 10
write() #1 returned: true | writableLength: 5
write() #2 returned: false | writableLength: 15
drain fired -- writableLength now: 0

Notice 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.

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.

Node.js
Output (verified on Node 24)
sink received: alpha
sink received: beta
sink received: gamma
stream finished
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"]
write()'s boolean return value is the whole backpressure contract: false means stop and wait for drain before writing again
ChoiceBenefitCost
Manual write()/drain handlingFull control over exactly when and how much to bufferVerbose and easy to get wrong — one missed check and memory grows without bound
.pipe() / pipeline()Handles the write()/drain dance for you automaticallyLess direct control; you consume the higher-level API’s error/completion contract instead of rolling your own
Checking writableLength directlyGives an exact, real-time buffered-byte (or object) count for monitoring/loggingOne more thing to read correctly — it means nothing without also knowing the current writableHighWaterMark
  • “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 to false; Node still accepts and buffers the chunk that pushed you over it.
  • “write() returning false means the write failed” — fact: the chunk was still accepted and will still be flushed; false is purely a signal to slow down, not an error.
  • “finish and close mean the same thing” — fact: finish fires once all buffered data has been flushed after end(); close fires when the underlying resource (a file descriptor, a socket) has actually been released, which can happen at a different time.
  • Does code call writable.write() in a loop without ever checking its boolean return value or listening for drain?
  • Does a custom Writable’s write(chunk, encoding, callback) implementation forget to call callback() 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 for finish (data flushed) or close (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.

What does writable.write() return when the internal buffer is full?
Which event fires after writable.end() is called and all data has been flushed?
What is the correct action when writable.write() returns false?
A Writable has highWaterMark: 10 (bytes). After one write() call with a 15-byte chunk, what does writableLength report, and what did write() return?
A custom Writable's write(chunk, encoding, callback) throws an exception instead of calling callback() on an error path. What is the observable symptom?
What is the difference between the finish and close events on a Writable?