← Debugging Guides

阅读中文版本

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.

intermediatedebuggingnodejsclistreams

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

Interpretation

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

Interpretation

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/ | head

Interpretation

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.