EPIPE Broken Pipe on a Write That Was Perfectly Legal
The crash report said write EPIPE. The stack trace pointed at a line of code that was, by every reading, correct. It was writing the response to an open request. Writing to an open request is the entire job of a server.
Except the request was not open anymore. The client had gone away, and the only thing wrong with the write was its timing.
This is one of those errors that is not a bug in your logic at all. It is the network informing you, rudely, that the other side of the conversation left. The interesting part is why your runtime treats that as a crash instead of a footnote.
This was Node 22.14 serving JSON over HTTPS, but the same shape appears in Go, Python and Ruby servers, and the reasoning transfers.
What EPIPE actually means
EPIPE is the kernel's way of saying: you tried to write to a pipe or socket that no longer has a reader.
When a TCP peer closes its end, and you write anyway, the kernel sends the data, the peer responds with a RST because it has no socket for it, and the next write on your side fails with EPIPE. So EPIPE typically shows up one write after the disconnect, which is why it feels unconnected from any user action.
The reason this is an error and not a silent no-op is historical. On Unix, writing to a broken pipe raises SIGPIPE, whose default action is to terminate the process. Runtimes generally disable the signal and surface EPIPE as a return code or exception instead, but the semantics stayed harsh: it is an error you are expected to handle, and if you do not, it propagates.
Why Node turns it into a crash
In Node, a socket error is emitted as an 'error' event on the socket. If nothing has attached an 'error' listener, Node's default is to throw the error as an unhandled exception, which crashes the process.
So the crash is not because EPIPE is fatal. It is because the error had nowhere to go. A client disconnect is completely routine, and a server that crashes on routine is misconfigured, not unlucky.
This is the same trap as unhandled promise rejections: the event is expected, the handling is not.
The fix is not to retry
I have seen teams add a retry around the write, which is exactly backwards. The peer is gone. Retrying a write to a closed socket will fail again, usually harder. There is nothing to retry.
The correct response to EPIPE is to stop. Stop writing to this socket, release its buffers, and move on. The client is not coming back for this response.
At the socket level:
res.socket.on("error", (err) => {
if (err.code === "EPIPE" || err.code === "ECONNRESET") {
res.socket.destroy(); // client left, nothing to do
return;
}
throw err;
});
Notice that ECONNRESET gets the same treatment. Both mean the peer went away mid conversation; they differ only in which side noticed first and how loudly.
Where it bites in real systems
The naive "just attach an error listener" fix works, but the pattern shows up in less obvious places.
Streaming responses. If you stream a large report and the client cancels, every subsequent write can EPIPE. Your stream keeps producing data nobody wants. Check res.writableEnded or listen for close and stop the producer. A report generator that keeps querying and serialising for a client that left is burning CPU for no one.
Websockets and long lived connections. A client that closes its laptop lid will EPIPE your next push. If you have a broadcaster looping over sockets, one dead socket must not kill the whole broadcast. Wrap each write and drop the dead socket from the set. This is the broadcast version of the same defect covered in websockets that disconnect behind a proxy.
Logging over the network. If you ship logs to a socket and the collector restarts, your logger can EPIPE and, if it throws, take down the application over a logging failure. Logging must never be allowed to crash the thing it is logging. Send it to a buffer with backpressure and drop on overflow.
Command line pipelines. The original SIGPIPE context. mytool | head closes the pipe after the first line, and a tool that ignores SIGPIPE crashes with EPIPE on a perfectly ordinary invocation. If your CLI is meant to be piped, let SIGPIPE terminate it quietly.
The distinction that matters
There are two different situations that both surface as a write failure, and they need different responses.
A peer disconnect (EPIPE, ECONNRESET after the request started) is routine. The correct response is to stop and clean up. It is not an incident and should not page anyone.
A write failure to a live peer (ENOMEM, a full buffer that never drains, an actual kernel error) is not routine and is worth surfacing.
If you lump both into one handler, you will either crash on routine disconnects or, worse, silently swallow real failures. Check the error code and treat the two classes differently. A counter for EPIPE is a metric to glance at, not an alert. A rising rate of non EPIPE socket errors is.
What I changed, and what I watch
For the service in question I made three changes. Every socket got an error handler that distinguishes peer disconnect from real failure. Streaming producers got wired to the response's close event so they stop when the client stops. And I added two counters: socket_disconnects{cause="client_left"} and socket_errors{cause="other"}, so the routine noise is visible as a line on a graph rather than as a crash in the log.
The crash rate went to zero, which I expected. What I did not expect is that the disconnect counter immediately showed me that a third of our traffic was cancelling requests early, which led to a completely separate investigation into a slow first byte. The EPIPE was not the problem. It was the symptom of clients giving up.
When you see a write error, the first question is not "how do I make the write succeed". It is "is the other end still there". Half the time the honest answer is no, and the fix is to let it go.
If your disconnects are happening at a suspiciously regular interval instead, that is a different signature, covered in connection reset by peer at exactly sixty seconds.