← Debugging Guides

阅读中文版本

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.

intermediatedebuggingnodejsstreamshttp

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

Interpretation

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.