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.
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.