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.

text
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

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.

js
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.

js
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.

js
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

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

Disadvantages

Checklist

Sources

Read this lesson as Markdown