Skip to content

Node.js Writable Streams

Writable streams represent a destination for data, files, HTTP responses, sockets. This lesson covers writing to them correctly, including the backpressure mechanism that keeps memory usage under control.

What Is a Writable Stream?

A Writable stream accepts chunks of data via .write() and signals completion with .end(). Internally, it manages an internal buffer, if you write data faster than the destination can actually consume it (a slow disk, a slow network connection), the stream buffers the excess temporarily.

.write() returns false when that internal buffer has exceeded a configurable threshold (highWaterMark), signaling that you should pause writing until the stream emits a 'drain' event. This mechanism is called backpressure, and respecting it prevents unbounded memory growth.

import fs from 'node:fs';

const output = fs.createWriteStream('./output.txt');

output.write('First line\n');
output.write('Second line\n');
output.end('Final line\n');

output.on('finish', () => {
  console.log('All data has been written.');
});

.end() accepts an optional final chunk and signals that no more data will be written; the 'finish' event fires once everything has actually flushed to the destination.

Writing to a Writable Stream

const ok = stream.write(chunk);
if (!ok) {
  stream.once('drain', () => { /* resume writing */ });
}
stream.end(finalChunk);
  • .write() returns true if it's safe to keep writing immediately, false if you should wait.
  • 'drain' fires once the internal buffer has emptied enough to safely resume writing.
  • .end() signals no more data will follow; 'finish' confirms everything has been flushed.
  • .pipe() handles all of this backpressure logic automatically for you.

Writable Streams Cheatsheet

Core methods and events on a Writable stream instance.

Method/Event Purpose
.write(chunk) Send a chunk of data, returns true/false
.end(chunk) Signal no more data will be written
'drain' Fires when it's safe to resume writing after backpressure
'finish' Fires once all written data has been flushed
'error' Fires on a write failure, must be handled
highWaterMark Configurable buffer size threshold triggering backpressure

Backpressure in Practice

Backpressure matters most when a fast Readable source is piped or manually written into a slower Writable destination, streaming a database export into a slow network connection, for example. Without respecting 'drain', you could buffer an unbounded amount of data in memory while waiting for the destination to catch up.

function writeAll(writable, chunks) {
  return new Promise((resolve, reject) => {
    writable.on('error', reject);

    function writeNext(i) {
      if (i >= chunks.length) {
        writable.end();
        return resolve();
      }

      const ok = writable.write(chunks[i]);
      if (ok) {
        writeNext(i + 1);
      } else {
        writable.once('drain', () => writeNext(i + 1));
      }
    }

    writeNext(0);
  });
}

This manual pattern is exactly what .pipe() implements for you automatically when connecting a Readable stream to a Writable one.

Creating a Custom Writable Stream

You can build a custom Writable stream to send data somewhere Node.js doesn't support out of the box, an in-memory buffer, a custom logging backend, or a third-party API.

import { Writable } from 'node:stream';

class UppercaseLogger extends Writable {
  _write(chunk, encoding, callback) {
    console.log(chunk.toString().toUpperCase());
    callback();
  }
}

const logger = new UppercaseLogger();
logger.write('hello streams');
logger.end();

The callback() inside _write() must be called once processing of that chunk is complete, it's how the stream knows it's safe to accept the next one.

Common Mistakes

  • Ignoring the false return value from .write() and continuing to write anyway, defeating backpressure.
  • Forgetting to call .end(), leaving the stream (and sometimes the underlying file/socket) open indefinitely.
  • Not handling the 'error' event on a writable stream, letting a write failure crash the process.
  • Forgetting to call callback() inside a custom _write() implementation, stalling the stream forever.

Key Takeaways

  • Writable streams accept data via .write() and are closed with .end().
  • Backpressure (.write() returning false, waiting for 'drain') prevents unbounded memory growth.
  • .pipe() automatically manages backpressure between a Readable source and Writable destination.
  • Custom Writable streams implement _write(chunk, encoding, callback) and must call callback().

Pro Tip

Prefer .pipe() (or stream.pipeline() from node:stream) over manually calling .write() in a loop whenever possible, both handle backpressure and error propagation correctly without the boilerplate shown in the manual example above.