NodeJSWritable Streams

Writable Streams

A Writable stream is a destination you push data into — a file being written, an HTTP response, process.stdout, a socket. You call write() to send chunks and end() to finish. The subtlety lives in write()'s return value, which is how a writable signals backpressure — and ignoring it is the single most common stream bug.

Basic writing

JS
import { createWriteStream } from 'node:fs'

const out = createWriteStream('output.txt')

out.write('first line\n')
out.write('second line\n')
out.end('last line\n')      // optional final chunk, then closes

out.on('finish', () => console.log('all data flushed'))
out.on('error',  (err) => console.error(err))
`end()` is required — it flushes and closes
Calling `end()` signals "no more data." It flushes any buffered content, fires `finish`, and releases the file handle/socket. Forgetting `end()` leaves the stream open: the file may be incomplete, an HTTP response never completes, and the resource leaks. You can pass a final chunk to `end(chunk)` as a shorthand for `write(chunk)` + `end()`.
write() returns a boolean — this is backpressure
`write()` returning `false` means the buffer is full — stop and wait for `drain`
`write(chunk)` returns `true` if the chunk was accepted into the internal buffer with room to spare, or `false` if the buffer has exceeded `highWaterMark`. `false` does **not** mean the write failed — the data is still queued — but it's your signal to **stop writing** until the `drain` event fires. Ignoring it and writing in a tight loop makes the buffer grow without bound, and the memory savings of streaming evaporate.

Respecting backpressure manually

JS
function writeMany(stream, items) {
  return new Promise((resolve, reject) => {
    let i = 0
    write()
    function write() {
      let ok = true
      while (i < items.length && ok) {
        const chunk = items[i++]
        if (i === items.length) {
          stream.end(chunk)              // last one
        } else {
          ok = stream.write(chunk)       // false ⇒ buffer full
        }
      }
      if (i < items.length) {
        // paused by backpressure — resume when drained
        stream.once('drain', write)
      }
    }
    stream.on('finish', resolve)
    stream.on('error', reject)
  })
}
`drain` fires when it's safe to resume
After `write()` returns `false`, the stream emits `drain` once its buffer has emptied below `highWaterMark`. The correct pattern is: write until `false`, wait for `drain`, then continue. This is exactly the dance `pipe`/`pipeline` perform for you — which is why you should let them handle it whenever possible.
Why pipeline beats manual writing

The manual backpressure loop above is correct but error-prone. When you're moving data from a readable, let pipeline own the whole dance:

JS
import { createReadStream, createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'

// Backpressure, errors, and cleanup all handled:
await pipeline(
  createReadStream('source.dat'),
  createWriteStream('dest.dat'),
)
The key events

Event

Meaning

drain

Buffer drained below highWaterMark — safe to write again

finish

All data flushed; end() was called and processed

close

Stream and underlying resource closed

error

A write or the underlying resource failed

pipe / unpipe

A readable started/stopped piping into this writable

`finish` and `close` are not the same
`finish` means *your* data was accepted and flushed by the stream; `close` means the underlying resource (file descriptor, socket) is fully released. With `autoDestroy` (the modern default) they usually fire close together, but for cleanup that depends on the OS handle being gone — like reading a file you just wrote — wait for `close`, not just `finish`.
cork and uncork: batching small writes

Many tiny write() calls each incur overhead. cork() buffers writes in memory; uncork() (or the next tick) flushes them as one larger operation — useful when emitting many small chunks back-to-back:

JS
socket.cork()
for (const part of headerParts) socket.write(part)
process.nextTick(() => socket.uncork())   // flush batched writes together
Always pair `cork` with `uncork`
A corked stream holds data in memory until uncorked — forget to uncork and your data never goes out. Uncork on `process.nextTick` (or `setImmediate`) so the synchronous burst of writes batches into one flush. Use this only when you've measured a real overhead from chatty writes; it's a micro-optimization, not a default.
Object mode and custom writables

JS
import { Writable } from 'node:stream'

// A sink that consumes objects and persists them:
const dbWriter = new Writable({
  objectMode: true,
  write(record, _encoding, callback) {
    saveToDatabase(record)
      .then(() => callback())      // signal "ready for next chunk"
      .catch(callback)             // pass error → emits 'error'
  },
})

dbWriter.write({ id: 1 })
dbWriter.end()
You MUST call `callback()` in `_write` — exactly once
The `callback` you receive is how you tell the stream "I've finished this chunk; send the next." Forget to call it and the stream **stalls forever** (no more writes, no `finish`). Call it more than once and you corrupt the stream's state. Call `callback(err)` to surface an error. This contract — call it once, when done — is the heart of implementing any writable.
Quick reference

Goal

Approach

Write then close

write(...) then end()

Move data from a readable

pipeline(readable, writable)

Respect backpressure

Stop on write() === false, resume on drain

Know when flushed

Wait for finish (data) / close (resource)

Batch small writes

cork()uncork()

Custom sink

new Writable({ write(chunk, enc, cb) })

Next
Streams that are readable and writable at once — and can transform data in flight: [Duplex & Transform Streams](/nodejs/duplex-transform-streams).