Streaming a Response, and Backpressure
The endpoint was four lines and it had been fine for two years.
app.get('/exports/:id.csv', async (req, res) => {
const csv = await readFile('/var/exports/' + req.params.id + '.csv', 'utf8');
res.type('text/csv').send(csv);
});
Then an account with four years of history hit it, and the request died before it ever reached send:
Error: Cannot create a string longer than 0x1fffffe8 characters
0x1fffffe8 is 536,870,888. Call it 512 MiB. That is V8’s hard ceiling on the length of a single string, Node exposes it as buffer.constants.MAX_STRING_LENGTH, and no flag raises it. So the endpoint was not slow on big files. It was impossible on big files, and nobody had noticed because the largest fixture in the test suite was 4 MB.
Someone dropped the 'utf8' argument to get a Buffer back instead of a string. That cleared the ceiling. Two weeks later three of those accounts exported at the same time and the pod was OOMKilled at 1.6 GB, which is the version of this bug that actually wakes you up, because it does not kill the request that caused it. It kills whichever unlucky request happened to be allocating when the allocator gave up.
Both attempts were the same mistake wearing different clothes. That file was never supposed to be in memory at all.
The memory graph is the whole argument
Here is the difference, measured rather than asserted. Same 500 MB file, same destination, Node 22 on a laptop, resident set size sampled while it runs:
readFile(path) then send rss 35 MB → 538 MB
createReadStream(path).pipe() rss 35 MB → 85 MB peak
The exact numbers do not matter much. The shapes do. Buffering costs you payload size × concurrent requests. Streaming costs you roughly 64 KiB × concurrent requests, because that is all the data a stream holds at once by default. Multiply it out and the two curves stop being a style preference:
| concurrent downloads | buffered, 500 MB each | streamed, 64 KiB each |
|---|---|---|
| 1 | 500 MB | 64 KiB |
| 10 | 5 GB | 640 KiB |
| 100 | 50 GB | 6.4 MB |
The buffered column is a load test that succeeds at killing you. The streamed column barely notices. And crucially, the streaming version starts sending bytes in milliseconds instead of after a full read, so your time-to-first-byte drops from “however long the disk takes” to “one chunk”.
So the four-line endpoint becomes this:
app.get('/exports/:id.csv', (req, res) => {
res.type('text/csv');
createReadStream(exportPath(req.params.id)).pipe(res); // don't ship this yet
});
That comment is doing real work. This version has two problems that only show up on a bad day, and both of them are the subject of the rest of this article.
Content-Length or chunked, and Node picks for you
The moment you stopped buffering, you gave something up. When you had the whole CSV in a Buffer you knew exactly how long it was, so res.send set Content-Length for you. Now you do not know, because you have not read the file yet.
Node handles this silently: if no Content-Length is set by the time headers go out, it adds Transfer-Encoding: chunked. Those are the only two ways HTTP/1.1 can tell a client where a body ends, and every response uses exactly one of them.
Chunked costs you a few bytes per chunk and costs your users the download progress bar, because there is no total to divide by. If you can cheaply learn the size, set it and get both back:
const info = await stat(file);
res.setHeader('Content-Length', info.size); // now the browser can draw a percentage
res.setHeader('Content-Type', 'text/csv');
createReadStream(file).pipe(res);
There is a race in there: the file could change between the stat and the read. For immutable export blobs that is not a real risk, for user-writable files it absolutely is, and if the length can drift then do not lie about it. Chunked is not a failure mode, it is just less informative. What the browser does with that header either way is covered in fetch download progress.
Backpressure is the consumer saying “not so fast”
Here is the second thing you gave up. createReadStream reads from a local SSD. res writes to a socket belonging to somebody on hotel wifi. The producer is faster than the consumer, possibly by three orders of magnitude, and something has to give.
That something is a queue, and backpressure is the mechanism that keeps the queue small.
Every Node writable stream has an internal queue and a threshold called the high water mark. Since Node 22 the default is 65536 bytes, 64 KiB, up from 16 KiB in earlier versions. You can read it back at runtime:
import { getDefaultHighWaterMark } from 'node:stream';
getDefaultHighWaterMark(false); // 65536 bytes
getDefaultHighWaterMark(true); // 16 objectMode counts objects, not bytes
res.writableHighWaterMark; // 65536
Now the part that trips everyone up. write() never refuses. It always accepts your chunk. What it does is return a boolean:
res.write(chunk); // → true: the queue has room, keep going
res.write(chunk); // → false: the queue is at or over the mark, please stop
false is advice, not enforcement. Nothing stops you ignoring it, and the stream will dutifully queue every byte you hand it. I measured what that costs. Two writers, same deliberately slow sink, 4000 fresh 64 KiB chunks:
ignoring write()'s return value: rss 34 MB → 294 MB, writableLength = 250 MB
honouring it with a drain loop: rss 35 MB → 89 MB, writableLength = 64 KiB
Read the second column again. Ignoring backpressure does not make anything faster. The socket still drains at exactly the speed the socket drains at. All you did was rebuild the buffering bug from the top of this article, one write() call at a time, inside a stream that was trying to help you. And it told you: in that first run write() returned false on the second chunk and writableNeedDrain stayed true for the entire rest of the loop. The information was right there in the return value being dropped on the floor.
The drain loop, written out once
If you are pushing chunks by hand, this is the shape. It is worth typing once so you recognise what pipe is doing for you later:
function pump(source, dest) {
let i = 0;
(function next() {
let ok = true;
while (i < source.length && ok) {
ok = dest.write(source[i++]); // false means the queue is at the mark
}
if (i < source.length) dest.once('drain', next); // stop. resume on 'drain'.
else dest.end();
})();
}
Two details make this correct. The while keeps writing while write() says true, so you are not paying an event per chunk. And 'drain' only fires after the queue has fully emptied, not the moment it dips below the mark, which is why you use once and re-enter rather than polling.
Play with it. Drag the producer above the consumer and watch the two lanes diverge: same delivered count, wildly different memory.
pipe does the drain loop. It does not do errors.
.pipe() exists so you never write that loop. It reads the return value of every write(), pauses the source when it gets false, resumes on 'drain', and calls end() when the source finishes. For the happy path it is exactly right, and it is why the one-liner at the top of this article was already better than the buffering version.
Then something fails, and you find the caveat. Node’s own docs put it plainly: if the readable emits an error during processing, the writable destination is not closed automatically. I did not want to take that on faith, so I measured it. Source errors mid-stream, destination checked immediately after:
after pipe(): dest.destroyed = false ← still open. still holding a socket.
after pipeline(): dest.destroyed = true ← and the promise rejected
The leak is not theoretical. Every abandoned pipe chain holds a file descriptor, a socket, and whatever the gzip transform had buffered, and none of it is reachable by your error handler because your error handler does not know it happened. Do this a few thousand times and you run out of file descriptors, which produces EMFILE on some completely unrelated request, three hours later, with a stack trace pointing at innocent code.
pipeline, and the one thing it will do to you
pipeline is pipe plus the cleanup. Import it from node:stream/promises and it returns a promise:
import { pipeline } from 'node:stream/promises';
await pipeline(createReadStream('big.csv'), createGzip(), res);
One failure anywhere rejects that promise, and on the way out it calls destroy(err) on every stream in the chain, with two documented exceptions: readables that already emitted 'end' or 'close', and writables that already emitted 'finish' or 'close'. Finished streams get left alone. It also takes an AbortSignal, which is how you cancel the whole chain at once when the client hangs up.
Prefer it over pipe by default. Not because it is newer. Because pipe has an error story you have to write yourself and nobody writes it.
One sharp edge to file away: pipeline leaves dangling event listeners on the streams after its callback runs, so reusing a stream after a failed pipeline can leak listeners and swallow errors. If the last stream is readable it does clean those up, so you can still consume it. In practice, do not reuse streams across pipelines. Build fresh ones.
The awkward truth: you already sent the 200
Here is the shape of the problem, and it is not a Node problem. It is HTTP.
Your handler writes the status line and headers. Those bytes are on the wire. They are through the load balancer, possibly cached, definitely read. Then, four megabytes into the body, the database connection drops.
You would like to send a 500. You cannot. Not because of an API limitation. Because you already told the client 200 OK and there is no packet you can send that unsays it.
I ran exactly this against a real server to check what each escape hatch does. None of them are escape hatches:
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write('[{"id":1}');
// ... the query throws here ...
res.headersSent; // true
res.writeHead(500); // throws ERR_HTTP_HEADERS_SENT: Cannot write headers after they are sent
res.setHeader('x', '1'); // throws ERR_HTTP_HEADERS_SENT: Cannot set headers after they are sent
res.statusCode = 500; // no throw at all. and the client still saw 200.
That last line is the trap. Assigning statusCode after the headers are gone is silent. It mutates a field nobody will ever read, so code that does it looks like it handles the error and does not. And here is what the client sees when you give up and destroy the socket:
const res = await fetch('/exports/1.csv');
res.status; // 200
res.ok; // true ← your `if (!res.ok) throw` guard sails right past
await res.text();
// TypeError: terminated
// cause: Error: other side closed { code: 'UND_ERR_SOCKET' }
Note where the failure lands. Not on the fetch, not on the status check, but on the body read, several lines later, as a TypeError. If the caller does const r = await fetch(u); if (!r.ok) return fallback(); return r.json(); then this blows up inside json() and looks, from the outside, like a JSON parse problem.
So what do you actually do
Four answers, in the order you should reach for them.
1. Fail before the first byte. The real fix, and it is boring. Do every fallible thing you can before writeHead: authorise, validate, stat the file, open the handle, run the query and pull the first row. Then send headers. You cannot make streaming failure-proof, but you can move most of the failures to the left of that dashed line, where they are still just a 500.
app.get('/exports/:id.csv', async (req, res, next) => {
const meta = await db.export.find(req.params.id); // 404 lives here
if (!meta) return next(new NotFound());
if (!canRead(req.user, meta)) return next(new Forbidden()); // 403 lives here
let handle;
try { handle = await open(meta.path); } // ENOENT lives here
catch (err) { return next(err); } // still a clean 500
res.writeHead(200, { 'Content-Type': 'text/csv' }); // ← past here, no more status codes
try {
await pipeline(handle.createReadStream(), res);
} catch (err) {
req.log.error({ err, id: meta.id }, 'export stream died mid-flight');
}
});
2. When it does fail mid-stream, destroy. Do not end. This is the one people get backwards. res.destroy() kills the socket, so the client sees a truncated body: a short read against Content-Length, or a chunked stream with no terminating zero chunk. Both are detectable, which is why that framing diagram mattered. Calling res.end() instead gives the client a complete, valid, wrong response. A truncated CSV that ends cleanly is a CSV that gets saved, cached by your CDN, and imported into someone’s accounting system.
3. Make the payload self-terminating. If truncation is detectable in your format as well as in the framing, you get defence in depth. A JSON array is accidentally good at this: chop [{"id":1},{"id":2} anywhere and JSON.parse throws, loudly, on the client. NDJSON is accidentally bad at it: every line is independently valid, so a truncated stream is silently a shorter stream. If you stream NDJSON, end it with a sentinel and have the client require it:
{"type":"row","id":1}
{"type":"row","id":2}
{"type":"end","count":2}
No end line, no trust. That is four bytes of protocol design that turns a silent data-loss bug into a loud one.
4. Trailers, with your expectations set low. HTTP/1.1 lets a chunked response append header fields after the body, which is exactly the escape hatch you wanted: send the body, then admit it went wrong. Node does it with res.addTrailers(), and gRPC leans on it heavily.
res.setHeader('Trailer', 'X-Stream-Status'); // declare it up front
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
// ... stream rows, then it all goes wrong ...
res.addTrailers({ 'X-Stream-Status': 'error: query timeout' });
res.end();
Server to server, where you control both ends, that is a legitimate design. For anything a browser touches, forget it: fetch exposes no trailers, intermediaries strip them, and you will have built a signal nobody can read. Use option 3.
Streaming JSON, and why the shape of your payload is a design decision
The pipeline examples all move opaque bytes. Real endpoints move records, and the moment you do that, buffering sneaks back in through the front door:
app.get('/api/orders', async (req, res) => {
const rows = await db.query('SELECT * FROM orders WHERE org_id = $1', [req.orgId]);
res.json(rows); // every row in memory, then a giant string of every row in memory
});
res.json calls JSON.stringify, which builds one string containing the entire result set. You are now holding the rows and their serialised form at once, and if that string crosses 512 MiB you are back to Cannot create a string longer than 0x1fffffe8 characters. Meanwhile the driver already buffered the full result set before you got a chance to say no.
The fix has two halves and you need both. Stream out of the database with a cursor, and stream into the response a record at a time:
app.get('/api/orders.ndjson', async (req, res) => {
const client = await pool.connect();
const query = new QueryStream( // 1. a cursor, not a full result set
'SELECT id, total_cents, created_at FROM orders WHERE org_id = $1',
[req.orgId], // 2. parameterised. always.
{ batchSize: 500 },
);
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
try {
await pipeline( // 3. row → line → socket, one at a time
client.query(query),
async function* (rows) {
for await (const row of rows) yield JSON.stringify(row) + '\n';
},
res,
);
} catch (err) {
req.log.error({ err }, 'orders stream failed');
res.destroy(); // truncate loudly. do not end() a lie.
} finally {
client.release();
}
});
pg-query-stream opens a server-side cursor and fetches batchSize rows at a time, so Postgres never materialises the whole set for you and Node never holds it. The async generator in the middle is a legal pipeline stage, and because it is async function*, backpressure flows through it for free: the generator is only pulled when res has room.
Proxying: the one-liner that is not
Passing a response through your server looks like the easiest streaming there is. It is the one with the most sharp edges.
// this is wrong in at least three ways
app.get('/files/*', async (req, res) => {
const upstream = await fetch(target(req));
res.writeHead(upstream.status, Object.fromEntries(upstream.headers));
Readable.fromWeb(upstream.body).pipe(res);
});
The bugs, in order of how long they take to find:
- Copying
content-encodingwholesale.fetchalready gunzipped that body. Forwardingcontent-encoding: gziptells the client to gunzip plain bytes, and it fails asERR_CONTENT_DECODING_FAILED, which looks nothing like a proxy bug.content-lengthis the same story: that was the compressed length, so your declared size and your actual body now disagree. - Copying hop-by-hop headers.
connection,transfer-encoding,keep-alivedescribe the upstream connection, not yours. They are not yours to forward. pipe, so no error propagation, and no way to stop pulling from upstream when your client disappears. You will happily download 4 GB from S3 for a browser tab that closed twenty minutes ago.
The version that survives production copies headers on purpose and cancels in both directions:
const FORWARD = ['content-type', 'etag', 'last-modified', 'cache-control', 'content-disposition'];
app.get('/files/*', async (req, res, next) => {
const ac = new AbortController();
res.on('close', () => { if (!res.writableFinished) ac.abort(); }); // they left → stop upstream
const upstream = await fetch(target(req), { signal: ac.signal });
if (!upstream.ok) return next(new UpstreamError(upstream.status)); // still before writeHead
for (const h of FORWARD) {
const v = upstream.headers.get(h);
if (v) res.setHeader(h, v);
}
res.writeHead(upstream.status);
try {
await pipeline(Readable.fromWeb(upstream.body), res, { signal: ac.signal });
} catch (err) {
if (!ac.signal.aborted) req.log.error({ err }, 'proxy stream failed');
}
});
res.on('close') fires on the happy path and the abandoned one, so the writableFinished check is what distinguishes “we finished” from “they left”. Skip it and you abort requests that already succeeded, which is harmless but fills your logs with lies.
Web Streams, and which API to reach for
Everything above is Node’s own stream API, which predates the web platform’s by about a decade. The Streams API (ReadableStream, WritableStream, TransformStream, pipeTo, pipeThrough) is the standard one, and it is what you get on Workers, Deno, and Bun. Node has it too, at node:stream/web, and it has been Stability 2, Stable since v18.
The bridges between the two worlds were the last piece to settle. Readable.toWeb(), Readable.fromWeb() and Writable.toWeb() shipped experimentally back in v17 and were marked stable in Node 24.0.0 and backported to 22.17.0. As of July 2026 that means every supported Node line has them stable: 26 is Current, 24 is Active LTS, 22 is in maintenance.
await pipeline(Readable.fromWeb(response.body), res); // a fetch body into a Node writable
const web = Readable.toWeb(createReadStream('big.csv')); // a Node readable out to web-stream land
Which do you use? Not a matter of taste, mostly a matter of where the code runs:
| Node streams | Web Streams | |
|---|---|---|
| Where | Node only | Node, Deno, Bun, Workers, browsers |
| Backpressure | write() returns false, 'drain' |
desiredSize, writer.ready |
| Compose | pipeline(a, b, c) |
a.pipeThrough(b).pipeTo(c) |
| Errors | rejects, destroys the chain | rejects the pipeTo promise |
| Ecosystem | every Node library, zlib, fs, http |
growing, but thinner on the server |
| Speed | faster in Node, fewer layers | a wrapper over Node streams in Node |
Rules of thumb that have held up for me. Inside a Node server, use Node streams: they are what fs, zlib and http actually speak, and Readable.fromWeb is a real adapter with real overhead. On an edge runtime you have no choice, and that is fine, because the concepts transfer one for one. desiredSize dropping to zero is write() returning false wearing a different hat. If you are writing a library meant to run in both, take and return web streams and adapt at the boundary. More on where the runtimes diverge in Node and other runtimes.
Summary
- Never read a large payload into memory to send it. Measured on a 500 MB file: buffering peaks at 538 MB of RSS, streaming peaks at 85 MB. Buffered cost scales with
size × concurrency; streamed cost scales with concurrency alone. - Reading a big file as a string does not fail slowly, it fails absolutely. V8 caps a single string at
0x1fffffe8characters, about 512 MiB, exposed asbuffer.constants.MAX_STRING_LENGTH. No flag raises it. - No
Content-Lengthmeans Node addsTransfer-Encoding: chunked. Both framings let a client detect truncation. Set the length yourself when you can cheaply know it, so the browser gets a progress bar. HTTP/2 bans chunked and frames the body itself. - Backpressure is the consumer telling the producer to slow down.
write()always accepts your chunk and returnsfalseas advice. The default high water mark is 64 KiB since Node 22. Ignoringfalsebought me 250 MB of queued data and zero extra throughput. - Prefer
pipelinefromnode:stream/promisesoverpipe.pipedoes backpressure but leaves the destination open when the source errors.pipelinecallsdestroy(err)on the whole chain, skipping streams that already finished, and rejects so you find out. - Once headers are flushed, the status code is final.
writeHeadthrowsERR_HTTP_HEADERS_SENT; assigningres.statusCodesilently does nothing. The client still sees200andok: true, and the failure surfaces later as aTypeErrorin its body reader. - Move failures to the left of
writeHead: auth, validation,stat, open the handle, fetch the first row. Then commit. - When it fails mid-stream,
res.destroy(), neverres.end(). A truncated body is detectable. A cleanly-ended wrong body gets cached and imported. - Streaming records needs a cursor at both ends.
res.json(rows)buffers twice. Use a server-side cursor plus NDJSON, and add a sentinel line so silent truncation becomes loud. - Node streams inside Node, Web Streams at the edge.
node:stream/webis stable, andReadable.toWeb/fromWebwent stable in Node 24.0.0 and 22.17.0.