---
title: Streaming
summary: 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.
topic: performance
order: 3
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

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

```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

- **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-Length` and 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 `write` respects backpressure, or the stream uses `pipeline`.
- A failure mid-stream destroys the connection.
- The database connection is released in a `finally`.

## Sources

- [Node.js — Backpressuring in Streams](https://nodejs.org/en/learn/modules/backpressuring-in-streams)
- [Node.js — stream.pipeline](https://nodejs.org/api/stream.html#streampipelinesource-transforms-destination-options)
- [MDN — Using server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
- Cloudflare documentation, *Error 524: a timeout occurred*.
