Learn / Performance / Lesson 15
Streaming
Send data as it is produced instead of building it all in memory first. How backpressure keeps a slow phone from filling your server's RAM, and what to do when a stream breaks halfway.
Last updated: 2026-09-20
What it is
A normal response is buffered: the server builds the whole answer in memory, then sends it. A streamed response sends each piece as soon as it exists: row by row, chunk by chunk. The first bytes leave in milliseconds, and the server never holds the whole thing at once.
buffered: query all rows → build one big string → send it
streamed: read 500 rows → send them → read 500 more → send them → …Why it is a rule
- Memory grows with the biggest customer, not the average one. A year of a busy client's ledger can be hundreds of thousands of rows. Buffered, one download of it can use more RAM than a small server has, and the process is killed with everyone else's requests inside it.
- Proxies give up on silent servers. Cloudflare answers 524 when the origin sends no response within 100 seconds, and Heroku's router drops a request that sends no first byte within 30 seconds. A long export that is built first and sent at the end dies at the proxy, even though the server finishes the work. Streaming sends the first byte straight away.
- People see progress. A download that starts at once feels fast even if the total time is the same.
How to do it
Read from the source in pieces
Use a database cursor, not one giant query. It hands you rows in batches while the rest wait in the database.
const cursor = client.query(new Cursor('SELECT * FROM ledger WHERE company_id = $1', [company]))
let rows
while ((rows = await cursor.read(500)).length) {
for (const row of rows) await write(res, toCsvLine(row))
}Respect backpressure
res.write() returns false when the network buffer is full: the client is reading slower than you are writing. Keep writing anyway and the data piles up in memory, which is exactly what streaming was meant to stop. Wait for 'drain' before writing more.
function write(res, chunk) {
if (res.destroyed) return Promise.resolve()
if (res.write(chunk)) return Promise.resolve()
return new Promise((resolve) => {
res.once('drain', resolve)
res.once('close', resolve)
})
}A phone on a bad connection is the case that matters. With backpressure, the database cursor simply pauses until the phone catches up, and memory stays flat.
Or let pipeline do it
When both ends are Node streams, stream.pipeline handles backpressure, errors and cleanup for you.
import { pipeline } from 'node:stream/promises'
import { createGzip } from 'node:zlib'
await pipeline(fs.createReadStream(file), createGzip(), res)When it breaks halfway
Once the first byte is sent, the status code has already gone out as 200. You cannot turn it into a 500 any more. So if the database fails halfway, destroy the connection (res.destroy()). The client gets a cut-off file, which is invalid and cannot be mistaken for a complete one. Quietly ending the response would hand over a file that looks whole.
Always release the database connection in a finally, including when the client disconnects early.
Other kinds of streaming
- Server-Sent Events (
text/event-stream): a one-way stream of updates from server to browser over plain HTTP. Good for live progress or notifications. - WebSockets: two-way, for chat, presence and live collaboration.
- Streaming a request: large uploads go to disk or storage as they arrive, instead of being parsed whole into memory.
How we do it here
Large downloads such as ledgers and register exports read from a database cursor and are written with backpressure, so memory stays flat no matter how big the file is. A stream that fails halfway destroys the connection rather than ending cleanly, so a broken file never looks complete. Pages too large to cache are streamed from disk.
Benefits
- Memory stays flat whatever the size of the result.
- The first byte leaves at once, so proxies and timeouts do not cut long exports.
- Slow clients slow only their own download, not the server.
Disadvantages
- You cannot change the status code or headers after the first byte, so errors are cruder.
- The total size is often unknown, so there is no
Content-Lengthand no exact progress bar. - A cursor holds a database connection for the whole download. Many slow downloads at once can use up the connection pool.
- More code than "query, then
res.json".
Checklist
- Anything that grows with a client's data is streamed or paged.
- Every
writerespects backpressure, or the stream usespipeline. - A failure mid-stream destroys the connection.
- The database connection is released in a
finally.
Sources
- Node.js — Backpressuring in Streams
- Node.js — stream.pipeline
- MDN — Using server-sent events
- Cloudflare documentation, Error 524: a timeout occurred.