javascript 75 lines · 3 tabs

Stream a Large JSON Array Response in Node.js Without Buffering

Shared by codesnips Sep 2026
3 tabs
const { Pool } = require('pg');
const Cursor = require('pg-cursor');

const pool = new Pool();

async function* streamUsers({ since, batchSize = 500 }) {
  const client = await pool.connect();
  const cursor = client.query(
    new Cursor(
      'SELECT id, email, created_at FROM users WHERE created_at >= $1 ORDER BY id',
      [since]
    )
  );

  try {
    for (;;) {
      const rows = await cursor.read(batchSize);
      if (rows.length === 0) break;
      for (const row of rows) yield row;
    }
  } finally {
    await cursor.close();
    client.release();
  }
}

module.exports = { streamUsers };
3 files · javascript Explain with highlit

Serializing a huge database result set into a JSON array with JSON.stringify forces the entire dataset into memory before a single byte reaches the client. For thousands or millions of rows that means unbounded heap growth and a slow time-to-first-byte. The pattern shown here streams rows straight from a database cursor through a Transform stream, emitting valid JSON incrementally while honoring Node's backpressure so memory stays flat regardless of result size.

The jsonArrayStream tab implements the core Transform in object mode on the readable side. It buffers nothing beyond the current chunk: on the first _transform call it prefixes [, and every subsequent object is prefixed with a comma. The trick is the first flag — it lets the stream emit valid JSON separators without knowing how many records will arrive. JSON.stringify runs per row, so only one object is serialized at a time. _flush closes the array with ], and crucially still writes [] when the source was empty, so the output is always parseable. Because it extends Transform, push respecting the return value gives cooperative backpressure for free.

The usersCursor tab exposes an async generator backed by a pg cursor via pg-query-stream. Fetching in batches (batchSize) keeps the round-trips reasonable while never materializing the full table. Using a generator means the consumer pulls rows on demand, which naturally pairs with a stream that slows down when the socket is congested.

The exportController tab wires the two together with stream.pipeline, which is the safe way to connect stages: it propagates errors, destroys every stream on failure, and prevents socket leaks. Setting Content-Type: application/json up front commits to a streamed body, and Readable.from adapts the async generator into a proper readable source.

A key edge case is error handling after headers are sent — once bytes flow, an HTTP error status can no longer be set, so the handler logs and destroys the response instead. This approach trades the simplicity of a single res.json for constant memory and early first-byte, which is exactly the right call for large exports, reporting endpoints, and any response whose size scales with data volume.


Related snips

Share this code

Here's the card — post it anywhere.

Stream a Large JSON Array Response in Node.js Without Buffering — share card
Link copied