Node.js — ERR_STREAM_PREMATURE_CLOSE on HTTP Responses
Find why Node.js pipelines report ERR_STREAM_PREMATURE_CLOSE, separate server-side aborts from client disconnects, and fix both the response handling and the error path.
Symptoms
- Error [ERR_STREAM_PREMATURE_CLOSE]: Premature close appears in logs for a small fraction of upstream requests
- The proxied response body is truncated: Content-Length says 51200 bytes but the client receives fewer
- The failure rate climbs when the client aborts downloads or when the upstream is slow
Possible causes
- The client disconnected mid-download and the proxy code treats the propagated close as an upstream failure
- stream.pipeline or pipeline-style helpers surface the close as ERR_STREAM_PREMATURE_CLOSE instead of a typed abort
- The upstream closed the connection before finishing the body, often after a proxy read timeout
- A response body was consumed after the request context was already destroyed
Diagnosis steps
Classify the failure by socket ownership
req.on('close', () => log('client aborted', { id: req.id, bytesSent: res.bytesWritten }));Interpretation
If the close event fires on the incoming request before the upstream responds, the disconnect originated downstream, and retry logic must not target the upstream.
Reproduce the client abort deterministically
curl -o /dev/null --max-time 1 https://app.example.com/large-fileInterpretation
A curl that exits after 1 second should produce exactly one ERR_STREAM_PREMATURE_CLOSE. More than one means your pipeline is destroying extra streams.
Check upstream timeout configuration
grep -R "proxy_read_timeout\|responseTimeout" conf/ src/Interpretation
A short read timeout paired with a slow upstream produces premature closes without any client involvement.
Root cause
The readable side of a stream ended with a close event instead of an end event, so Node could not tell a completed body from a truncated one. Client aborts and upstream timeouts both surface through this same path when the code does not separate the two owners of the socket.
Fix
Handle client disconnects where they happen: listen for aborted or close on the request, cancel the upstream fetch with its AbortController, and classify ERR_STREAM_PREMATURE_CLOSE on the incoming side as expected rather than an upstream 5xx. Keep stream.pipeline for data plumbing and add explicit error handling per segment.
Risk note
Mapping every premature close to a 502 skews availability dashboards and pages on-call for client-side behavior. Log the two classes separately.
Verify the fix
Re-run the 1-second curl abort and a slow-upstream scenario. Confirm client aborts log as cancelled with no upstream retry, while genuine upstream truncation still surfaces as an upstream error with the truncated byte count.
Symptom walkthrough
ERR_STREAM_PREMATURE_CLOSE means a stream in the pipeline closed before it delivered the end event. In a proxy the same error covers two very different situations:
Error [ERR_STREAM_PREMATURE_CLOSE]: Premature close
at node:internal/webstreams/adapters.js:481:18
The stack usually points at the web streams adapter, which tells you the boundary involved is an HTTP response body, not a file read. The bytes-sent counter at the moment of failure tells you which side walked away: zero or few bytes points to the upstream; a large partial count points to the downloading client.
Fix in practice
Split the proxy into two independently handled halves so each socket has exactly one error path:
app.get('/large-file', async (req, res) => {
const upstream = await fetch(upstreamUrl);
req.on('close', () => {
if (!res.writableEnded) {
log.info({ id: req.id, bytes: res.bytesWritten }, 'client aborted');
upstream.body.cancel().catch(() => {});
}
});
await pipeline(Readable.fromWeb(upstream.body), res)
.catch((err) => {
if (!res.writableEnded) res.destroy(err);
});
});
The critical detail is that the client abort path cancels the upstream body without throwing into the pipeline, and the upstream failure path destroys the response so the client sees a clean truncation error instead of a silent short body.
Verification
Run two deterministic reproducers. First, curl with a 1-second limit should produce one cancelled log line and zero upstream errors. Second, point the proxy at an endpoint that writes half a body and stalls; the client must receive an error, not a response that silently ends early. Compare res.bytesWritten against the upstream Content-Length in both runs to confirm the accounting is exact.