Node.js — EPIPE Crashes When Piping stdout to head
Stop Node.js from crashing with EPIPE when consumers close the pipe early, distinguish write-after-close from reader death, and exit gracefully on expected closes.
Symptoms
- Error: write EPIPE at afterWriteDispatched when the output is piped into head, grep -m, or a closed socket
- The CLI works when output goes to a terminal but dies when piped
- The process exits non-zero with no partial output issue reported by the user
Possible causes
- The reader (head, grep -m1, less -F) exits after consuming enough, and the writer keeps writing into the closed pipe
- process.stdout writes are asynchronous, so the failure surfaces in a later event-loop turn with an uncaught error
- A SIGPIPE-equivalent surfaces as EPIPE on Windows where signal handlers differ
- A long-running stream (for example a log tailer) keeps writing after the consumer disconnects
Diagnosis steps
Reproduce the exact pipeline shape
node emit.js | head -5Interpretation
If emit.js writes more than five lines, head exits after five and every subsequent write races into a closed pipe — the deterministic reproduction.
Confirm the error is on stdout specifically
node emit.js | head -5 2>&1 | catInterpretation
The stack naming process.stdout (a Socket) rather than a file descriptor wrapper confirms the pipe writer path.
Check for existing error handling
grep -R "EPIPE\|stdout.on" src/ | headInterpretation
No handler on stdout error means the write-after-close rejection becomes a crash by default.
Root cause
The downstream reader closed the pipe while the Node process still had writes queued. Writes to process.stdout are asynchronous, so the closed-pipe rejection arrives on a later turn of the event loop as an error event with code EPIPE. With no error handler attached, that event becomes an uncaught exception and kills the process.
Fix
Attach an error handler to stdout and stderr that treats EPIPE as a clean exit, stop writing once the stream reports closed or errored, and for CLIs check writable before writes and exit with code 0 when the consumer simply had enough.
Risk note
Swallowing every EPIPE hides genuine consumer crashes in service-to-service streams. Match the graceful exit only to short-lived consumer-exit patterns.
Verify the fix
Pipe the CLI into head, grep -m1, and wc -l, asserting exit code 0 for all three and no stack traces; confirm full-output runs still succeed when piped to cat.
Symptom walkthrough
The crash only appears when someone narrows your output:
node emit.js | head -5
Error: write EPIPE
at afterWriteDispatched (node:internal/stream_base_commons:162:15)
head exits after its five lines; the writer discovers the closed pipe only when an asynchronous write completes. Terminal output never triggers it because a tty reader does not exit early. The error object has code EPIPE and syscall write, and the stack references process.stdout — a Socket, not a plain file — which confirms the pipe writer path.
Fix in practice
Make the consumer-enough case an explicit, clean shutdown:
process.stdout.on('error', (err) => {
if (err.code === 'EPIPE') {
process.exit(0); // consumer had enough; not a failure
}
throw err;
});
For generators that can check before writing, stop early instead of racing the pipe:
for await (const row of source) {
if (process.stdout.destroyed) break; // reader is gone
write(row);
}
Apply the same handler to process.stderr, because log pipelines close early too. In long-running services writing to a log socket, treat the error handler as the reconnect trigger rather than an exit path.
Verification
Run the three canonical pipelines: into head, into grep -m1, and into wc -l. All three must exit 0 with no stack trace, because each represents a legitimate early close. The control case matters equally: piping into cat (full consumption) must still deliver every line, proving the EPIPE path did not truncate normal runs. Repeat under set -o pipefail in CI scripts so a regression to non-zero exits fails the build.