~/blog

.pipe() does not forward errors: one missing file kills the server

published

#node#streams#http#errors

TL;DR

readable.pipe(writable) moves data and nothing else. An error stays on the stream that emitted it, so createReadStream('./export.csv').pipe(res) takes the whole process down the first time the file is missing, and adding an error listener turns the crash into a response that never ends. The other direction leaks too: when the client disconnects, the file stream is paused, not closed. pipeline() from node:stream/promises destroys every stream on any failure and rejects once. It also destroys res, so decide on the 404 before you start piping.

The problem

The download handler everyone writes once:

import { createReadStream } from 'node:fs';
import { createServer } from 'node:http';

createServer((req, res) => {
  createReadStream('./export.csv').pipe(res);
}).listen(3000);

With export.csv missing, one request is enough. On Node.js v26.2.0 the server prints this and exits with code 1 (path shortened):

node:events:487
      throw er; // Unhandled 'error' event
      ^

Error: ENOENT: no such file or directory, open '…/export.csv'
Emitted 'error' event on ReadStream instance at:

The client gets nothing back; curl on Windows reports curl: (56) Recv failure: Connection was reset. Every other request that process was serving dies with it.

Why it happens

Two documented rules combine.

pipe() forwards data, backpressure and the end of the stream, not errors. The Node.js stream docs say so under readable.pipe(): “One important caveat is that if the Readable stream emits an error during processing, the Writable destination is not closed automatically. If an error occurs, it will be necessary to manually close each stream in order to prevent memory leaks.”

An 'error' event with no listener is fatal. From the events docs: “If an EventEmitter does not have at least one listener registered for the 'error' event, and an 'error' event is emitted, the error is thrown, a stack trace is printed, and the Node.js process exits.”

createReadStream() opens the file asynchronously, so ENOENT arrives as an 'error' event after the request handler has already returned. A try/catch around the call never sees it, and pipe() attached no listener on your behalf.

Adding an error listener is not the fix

The usual patch:

import { createReadStream } from 'node:fs';
import { createServer } from 'node:http';

createServer((req, res) => {
  const file = createReadStream('./export.csv');
  file.on('error', (err) => console.error(err.code));
  file.pipe(res);
}).listen(3000);

The process now survives and logs ENOENT. The listener stopped the crash, and nothing ends the response, so the client waits for bytes that will never come. With a two-second limit, curl gives up with curl: (28) Operation timed out after 2013 milliseconds with 0 bytes received.

That is one of four ways the pair can fail. Each row was reproduced on Node.js v26.2.0, with the destination an HTTP response or a plain Writable:

What failsWith .pipe()With pipeline()
Source errors, no 'error' listenerProcess exits with Unhandled 'error' eventPromise rejects with the error
Source errors, listener attachedDestination is neither ended nor destroyedDestination destroyed
Destination errorsSource paused (readableFlowing === false), not destroyedSource destroyed
HTTP client disconnects mid-downloadFile stream not destroyedFile stream destroyed, promise rejects with ERR_STREAM_PREMATURE_CLOSE

The last two rows are the quiet ones. Nothing is logged, and with .pipe() every aborted download leaves a paused ReadStream behind, holding its file descriptor, because a file stream only closes it when it ends or is destroyed. A busy server collects them, and the descriptors stay open until the process hits its limit.

What to do

Use pipeline() from node:stream/promises. The pipeline() docs describe it as “A module method to pipe between streams and generators forwarding errors and properly cleaning up”, and the cleanup is specific: “stream.pipeline() will call stream.destroy(err) on all streams except” readables that have already emitted 'end' or 'close' and writables that have already emitted 'finish' or 'close'.

The direct translation has a catch:

import { createReadStream } from 'node:fs';
import { createServer } from 'node:http';
import { pipeline } from 'node:stream/promises';

createServer(async (req, res) => {
  try {
    await pipeline(createReadStream('./export.csv'), res);
  } catch (err) {
    if (!res.headersSent) res.writeHead(404).end(); // too late: res is destroyed
  }
}).listen(3000);

The server survives, and the client gets curl: (52) Empty reply from server instead of a 404. pipeline() did what it promises and destroyed res along with the file, so by the time the catch runs there is no response left to write to. res.headersSent is still false, which is why the check does not help.

So open the file first, while the response is still yours:

import { open } from 'node:fs/promises';
import { createServer } from 'node:http';
import { pipeline } from 'node:stream/promises';

createServer(async (req, res) => {
  let fh;
  try {
    fh = await open('./export.csv');
  } catch (err) {
    res.writeHead(err.code === 'ENOENT' ? 404 : 500).end();
    return;
  }

  res.writeHead(200, { 'content-type': 'text/csv' });
  try {
    await pipeline(fh.createReadStream(), res);
  } catch (err) {
    if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error(err);
  }
}).listen(3000);

With the file missing, curl now gets HTTP/1.1 404 Not Found; with it present, HTTP/1.1 200 OK and the CSV. Each failure has one owner:

The descriptor is not leaked on a hang-up either. A stream from filehandle.createReadStream() defaults to autoClose: true, which the fs docs define as “on 'error' or 'end' the file descriptor will be closed automatically”. After a client disconnected mid-download, the handle’s fd read -1.

Caveats

References