Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Node Writable write Returns False: Wait, Then Send the Next Chunk

Avoid retrying a chunk that the writable stream has already accepted.

By ON Lab3 min readSources checked November 10, 2026 (KST)

A false return from writable.write means stop producing until drain; it does not mean the submitted chunk was rejected. Retrying that same chunk can duplicate bytes. Keep completion and error handling separate from flow control.

Reproduce the boundary in a disposable fixture

Avoid retrying a chunk that the writable stream has already accepted.

Use a new empty temporary directory for this fixture. The example creates only local test data. Commands marked with example.invalid do not identify a real service.

const assert = require('node:assert/strict');
const {Writable} = require('node:stream');
const {once} = require('node:events');
const {finished} = require('node:stream/promises');
(async () => {
  const seen = [];
  const sink = new Writable({highWaterMark:1,
    write(chunk, encoding, done) {
      seen.push(chunk.toString());
      setImmediate(done);
    }
  });
  const completion = finished(sink);
  // Observe completion rejection immediately, including during drain waits.
  completion.catch(() => {});
  for (const chunk of ['a','b','c']) {
    if (!sink.write(chunk)) await once(sink,'drain');
  }
  sink.end();
  await completion;
  assert.equal(seen.join(''),'abc');
  console.log(seen.join(''));
})().catch(err => {console.error(err);process.exitCode=1;});

Observed result

abc

What the result means

The tiny highWaterMark forces the producer to pause frequently. Each loop iteration writes a chunk once and waits only after that submission. The sink receives abc, not duplicated letters. The asynchronous write callback models a slower destination. finished observes final completion, while drain only indicates that production may resume; drain by itself does not mean the destination is done.

Choose the correction for your workflow

For a custom producer, advance to the next chunk after drain rather than resubmitting the previous one. Install completion and error observation before producing. For an existing readable source and writable destination, prefer pipeline when its lifecycle contract fits: it connects flow control and error propagation without a hand-written loop. If you must own the loop, also define cancellation, early close and source cleanup. A generic production helper must stop drain waits if the destination terminates early; this bounded successful fixture does not claim to implement that entire state machine.

Limits and the next check

highWaterMark is a buffering threshold, not a strict process memory ceiling. A producer can still allocate an enormous chunk before write. The fixture retains output solely to assert a three-byte result and is not a pattern for retaining a real export in memory. Read the target stream documentation for callback completion guarantees and any application-level durability requirement. Successful stream completion is not proof of a committed remote transaction.

Related: Compare the related workflow boundary.

This isolated example was executed on macOS on November 10, 2026 (KST). Available tools: node v26.5.0, git git version 2.39.3 (Apple Git-145), npm 11.17.0, jq jq-1.7.1-apple. Shell fixtures use Bash and repository-path identity uses Python 3. This checks the illustrated contract, not your project, host service or every tool version.

Sources & further reading