← Debugging Guides

阅读中文版本

Node.js — ERR_STREAM_WRITE_AFTER_END in Response Streams

Diagnose write after end errors on Node.js streams, find the double callback or late write in retry and middleware paths, and guard the writable lifecycle.

intermediatedebuggingnodejsstreamshttp

Symptoms

  • Error [ERR_STREAM_WRITE_AFTER_END]: write after end at _write (node:internal/streams/Writable:355:11)
  • The error appears after error-handling middleware already responded, or after a retry re-entered a handler
  • One request can produce both a 500 and a correct-looking response, or a hung socket

Possible causes

  • A callback runs twice: once on success, once on a late error, and each invocation writes to the response
  • Error middleware sends a response while the original handler continues and also writes
  • A retry wrapper re-executes a function that captured the already-ended res object
  • A race between a cached response write and the normal flow both end the stream

Diagnosis steps

Instrument the write and end calls

const w = res.write.bind(res); res.write = (...a) => (log('write'), w(...a)); const e = res.end.bind(res); res.end = (...a) => (log('end'), e(...a));

Interpretation

The log sequence — end followed by write — exposes the caller that writes into the finished stream, with timestamps to order the race.

Check the double-callback shapes

grep -Rn "callback(" src/ | grep -v "return callback"

Interpretation

A callback invocation not preceded by return runs the risk of falling through to a second invocation on the next line or in a catch block.

Reproduce the late-arrival path

curl -s http://localhost:3000/flaky?delay=2000 -o /dev/null -w "%{http_code}\n"

Interpretation

A delayed dependency that errors after the success path responded reproduces the exact double-write shape.

Root cause

The writable stream received a write call after end had already finished it. In HTTP handlers this almost always means two code paths both own the response — success and error, original and retry, cache and network — and the second one to fire writes into a stream the first one already completed.

Fix

Enforce single ownership of the response: guard with res.writableEnded or a responded flag checked before every write, return immediately after delegating to error middleware, and make callbacks single-shot with a done wrapper so double invocation is impossible rather than merely discouraged.

Risk note

Silently discarding the late write hides the underlying double-execution bug. Log the discarded payload with its trigger so the control-flow fault stays visible.

Verify the fix

Re-run the delayed-error and retry scenarios and assert exactly one end per request, no ERR_STREAM_WRITE_AFTER_END, and a clean socket close with no hanging connections.

Symptom walkthrough

The error is precise about the crime, and the crime is always an ordering problem:

Error [ERR_STREAM_WRITE_AFTER_END]: write after end
    at _write (node:internal/streams/Writable:355:11)

Wrap the response methods to see the sequence with your own eyes:

const origEnd = res.end.bind(res);
res.end = (...args) => { console.log('END', Date.now()); return origEnd(...args); };
const origWrite = res.write.bind(res);
res.write = (...args) => { console.log('WRITE', Date.now()); return origWrite(...args); };

END before WRITE in the log for the same request is the entire diagnosis. The usual suspects are a callback invoked on both success and a late error, or error middleware responding while the handler keeps running.

Fix in practice

Make response ownership single and explicit. A once-guard at the top of every writer:

function sendOnce(res) {
  let used = false;
  return (status, body) => {
    if (used || res.writableEnded) {
      log.warn('discarded late write', { status, preview: String(body).slice(0, 200) });
      return;
    }
    used = true;
    res.status(status).json(body);
  };
}

Make callbacks single-shot at the source so double invocation cannot happen:

function once(fn) {
  let called = false;
  return (...args) => { if (called) return; called = true; fn(...args); };
}

doWork(input, once((err, data) => {
  if (err) return next(err); // return stops the fall-through
  send(data);
}));

Audit retry wrappers for closures that captured res and can re-enter after the first attempt already responded. The late write must be logged, not swallowed silently — the discard event is your window into the control-flow bug underneath.

Verification

Reproduce both trigger shapes with a fixture endpoint: one where the dependency errors 2 seconds after success was already sent, one where a retry re-fires a handler holding the original res. After the fix, the per-request log must show exactly one END, zero ERR_STREAM_WRITE_AFTER_END, and one discard warning naming the late writer. Close with a socket-level check: ss -tan | grep ESTAB counts must return to baseline after the test batch, proving no half-open sockets remain from double-finished responses.