Node.js

Node.js — ERR_STREAM_PREMATURE_CLOSE during a piped upload

Written and reviewed by Sahil Srivastav

StreamsUploadsPartial data
Error [ERR_STREAM_PREMATURE_CLOSE]: Premature close

What this error actually means

A stream closed before the completion expected by the operation monitoring it. Close means the underlying resource has closed; it does not prove the readable reached its normal end or the writable finished accepting all input. A pipeline can therefore reject even though a close event was observed on every component.

An upload crosses several ownership boundaries: client connection, proxy buffering, request body, parser or transform, and storage destination. Failure in one component often destroys the others. ERR_STREAM_PREMATURE_CLOSE can be the consequence of that teardown rather than the first cause, so collect the earliest error and cancellation reason in the chain.

A successfully closed local file is also not automatically a complete business upload. It can contain only the prefix sent before the client disconnected. The publication boundary should require successful pipeline completion plus the application’s expected length, checksum or format validation where that information is part of the contract.

Causes, most common first

  1. 1The client or proxy abandons the request body. A cancelled browser request, mobile connection loss, body timeout or proxy limit can cut the readable short. Compare bytes received with the expected transfer and correlate proxy logs. This is not repaired by ignoring the pipeline rejection and accepting the bytes already written.
  2. 2The destination fails and destroys the source. Disk exhaustion, permission failures or an object-store upload error may initiate teardown. The request stream then closes as a consequence. Preserve the storage error instead of reporting only the final premature-close event.
  3. 3Application code ends or destroys a stream too early. A handler returns a success response before awaiting the pipeline, a timeout closes the destination, or parser completion is confused with destination completion. Each stage has its own lifecycle; a parser accepting metadata does not mean file bytes are durably accepted.
  4. 4Stream ownership is split across incompatible helpers. A parser and manual data listener both consume the body, or an upstream helper destroys a stream another component still owns. Mixing pipe chains with separate finish callbacks makes it difficult to know which promise covers the entire operation.

When you see it

  • Large uploads leave smaller files after a browser cancellation or proxy timeout
  • The upload route reports close before its storage pipeline completes
  • The first storage error is followed by several less specific stream errors
  • Retries create duplicate temporary objects or expose partially uploaded files

How to diagnose it

Step 1

Record the first failure and byte progress

Attach diagnostics before starting the transfer. Record request ID, bytes accepted, source completion, destination completion and the first error code. Avoid logging payload contents. A later close on a destroyed stream should not overwrite an earlier ENOSPC or abort reason.

Step 2

Reproduce client cancellation deliberately

Use a disposable endpoint and a local fixture larger than the amount the rate limit can send before the deadline. The command intentionally aborts; verify that no final object appears and temporary data is removed.

curl --limit-rate 32k --max-time 2 --data-binary @large-fixture.bin http://localhost:3000/upload

Step 3

Distinguish end, finish and close

For the incoming HTTP request, inspect req.complete after failure to distinguish a fully received message from truncation. For a writable, finish indicates its writes completed according to that stream’s contract; close alone is insufficient. Storage SDK completion may impose an additional commit operation.

Step 4

Test destination failure independently

In staging, use a destination that fails after a known byte count. Confirm the source is stopped, the pipeline rejects once, cleanup runs, and the response does not attempt a second write after headers were already committed.

The fix

Use the promise-based pipeline API to give one await ownership of the connected streams. It propagates failure and applies backpressure between compatible stages. Do not combine a fire-and-forget pipe call with a success response that only waits for metadata parsing.

Write into an unpublished temporary file or object, validate completion and then publish atomically where the storage system supports it. Remove incomplete temporary data on every failure path. For multipart object storage, explicitly abort unfinished upload sessions rather than assuming dropping the stream releases remote resources.

Treat expected client cancellation as a failed transfer with a clear outcome, not necessarily a server incident. Keep metrics for client aborts separate from storage failures. Propagate the abort to expensive transforms and remote storage work so disconnected clients do not consume capacity indefinitely.

Choose the HTTP error behaviour deliberately. pipeline can destroy an incoming request socket on failure, so a JSON error response may no longer be deliverable. Validate headers and admission before piping, then avoid writing a second response to a destroyed or already committed connection.

import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { rename, rm } from "node:fs/promises";

async function storeUpload(source, temporaryPath, finalPath) {
  // Paths are server-generated, unique and on the same filesystem.
  try {
    await pipeline(source, createWriteStream(temporaryPath, { flags: "wx" }));
    await validateCompletedUpload(temporaryPath);
    await rename(temporaryPath, finalPath);
  } catch (error) {
    await rm(temporaryPath, { force: true }).catch(cleanupError => {
      console.error("temporary upload cleanup failed", cleanupError);
    });
    throw error;
  }
}

How to stop it coming back

  • Test interrupted input, destination failure and validation failure separately
  • Keep temporary uploads out of the public namespace and expire abandoned objects
  • Monitor accepted bytes and completion counts rather than treating close as success
  • Ensure exactly one component owns each stream’s consumption and destruction

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Can I ignore premature close if the file exists?

No. The file may contain only a prefix. Publish only after the pipeline and required validation succeed, and remove incomplete output so a retry cannot accidentally reuse it.

Does finish mean the bytes are crash-durable?

It reflects the writable stream’s completion contract, not every storage durability guarantee. Filesystem persistence or remote multipart finalisation may require additional operations. Define the durability boundary separately from successful stream transfer.

Why can the server not send an error JSON after pipeline fails?

The pipeline may already have destroyed the incoming socket, or response headers may have been sent. Check response state and follow the framework’s error contract; another write can create ERR_HTTP_HEADERS_SENT or an additional connection error.

Related

Other errors engineers hit next to this one

Full error and symptom index →