.pipe() does not forward errors: one missing file kills the server
published
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 fails | With .pipe() | With pipeline() |
|---|---|---|
Source errors, no 'error' listener | Process exits with Unhandled 'error' event | Promise rejects with the error |
| Source errors, listener attached | Destination is neither ended nor destroyed | Destination destroyed |
| Destination errors | Source paused (readableFlowing === false), not destroyed | Source destroyed |
| HTTP client disconnects mid-download | File stream not destroyed | File 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:
- Before the first byte, a failure is a status code.
await open()rejects before anything has been written, so a missing or unreadable file becomes a 404 or 500 while headers can still be sent. - After
writeHead(200), destroying the response is the right signal. The client sees a truncated body and a reset connection instead of a short file that looks complete. ERR_STREAM_PREMATURE_CLOSEis a client hanging up. It is routine on any download endpoint, so it is the one rejection not worth logging.
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
- The examples serve one fixed file on purpose. If the path comes from the URL,
'.' + req.urllets../walk out of the directory; validate it first, as in the path.resolve post, or use a static-file library. pipeline()leaves listeners behind. The docs warn that it “leaves dangling event listeners on the streams after thecallbackhas been invoked. In the case of reuse of streams after failure, this can cause event listener leaks and swallowed errors.” Treat every stream in a failed pipeline as finished.- Versions. The callback form,
stream.pipeline(source, ...transforms, destination, callback), was added in Node.js v10.0.0; the promise form innode:stream/promisesin v15.0.0, along with the rest of the Streams Promises API. The callback form needs the callback; the promise form is the one that works withtry/catch. pipe()is still fine where nothing can fail on its own, such as aTransformyou control writing toprocess.stdout. The trouble starts when either end touches the filesystem or the network.- Output quoted here is from Node.js v26.2.0 on Windows. The curl messages come from curl on the same machine; other builds word a reset connection differently, but the status codes and the crash are the same.