Skip to content
Node.js nd streams 4 min read

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() returnsMeaningWhat to do
trueBuffer below highWaterMarkContinue writing
falseBuffer fullPause 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

MemberTypePurpose
write(chunk)methodQueue a chunk; returns false when buffer is full
end([chunk])methodFinish writing, optionally with a final chunk
draineventBuffer emptied enough to resume writing
finisheventAll data flushed after end()
erroreventAn error occurred during writing

Best Practices

  • Always check the return value of write() and pause when it is false; resume on drain.
  • Call end() exactly once — never write() after end(), which throws an ERR_STREAM_WRITE_AFTER_END.
  • Attach an error listener to every writable stream to avoid crashing on I/O failures.
  • Prefer pipeline() or pipe() over manual loops when copying between streams — they handle backpressure and cleanup for you.
  • Tune highWaterMark only when profiling shows a real need; the defaults (16 KB for files) are sensible.
  • Treat finish, not the return of end(), as the signal that data has actually reached the destination.
Last updated June 14, 2026
Was this helpful?