10LOC
Node.jsadvanced

Streaming a large JSON array without loading it into memory

Published December 18, 2026

import type { Writable } from "node:stream";

export const streamJsonArray = async (rows: AsyncIterable<unknown>, out: Writable) => {
  out.write("[");
  let first = true;
  for await (const row of rows) {
    out.write(`${first ? "" : ","}${JSON.stringify(row)}`);
    first = false;
  }
  out.write("]");
  out.end();
};

// Works with any async source, e.g. a database cursor:
// async function* fetchRows() { yield { id: 1 }; yield { id: 2 }; }
// streamJsonArray(fetchRows(), process.stdout);

What

streamJsonArray writes a valid JSON array to any Writable — an HTTP response, a file, process.stdout — one item at a time as an async source produces them, so the full array never exists as a single in-memory value.

Why it matters

res.end(JSON.stringify(hugeArray)) requires two full copies of the data to exist at once: the array of JS objects, and the serialized string JSON.stringify builds before you can send a single byte of it. For a genuinely large result set — an export endpoint, a big table dump — that's the exact shape of problem that takes down a process on memory, and it happens for something that doesn't need to hold the whole thing in memory at all: JSON arrays are just delimited items, and delimiters are cheap to emit one at a time.

The fix is to never materialize the array as a JS value in the first place. If the data already comes from something iterable incrementally — a database cursor, a paginated API, a generator — write the framing ([, ,, ]) around each item as it arrives instead of collecting them first.

How it works

  • rows: AsyncIterable<unknown> accepts anything you can for await over — an async generator wrapping a DB cursor is the common case, shown commented at the bottom.
  • The function writes the opening [ immediately, before a single row has even been fetched.
  • Each iteration of for await (const row of rows) serializes exactly one item with JSON.stringify(row) — never the whole collection — and writes a leading comma for every item after the first.
  • out.write("]") then out.end() close the array and the stream once the async source is exhausted.

Gotchas

  • This covers writing a large array; genuinely streaming the parsing of a large incoming JSON array (say, a multi-gigabyte upload) is a harder problem — a JSON value can't be split at arbitrary byte boundaries, so it needs a real streaming parser (a state machine tracking tokens), not a hand-rolled one. Reach for a library like stream-json for that direction rather than reinventing a parser.
  • out.write() returns false when the writable's internal buffer is full; this snippet ignores that for brevity. Under real backpressure, either await a 'drain' event or run this through pipeline() with a source stream instead of a bare loop.
  • Every item still gets fully serialized in memory one at a time — this bounds memory to "one row," not zero. That's the point, but it's not literally zero-copy.

Related

  • Readable.from() — turns the same kind of async iterable into a real stream, useful when you want to pipe() it rather than write to a Writable directly.
  • NDJSON (newline-delimited JSON) — an alternative wire format where each line is a complete JSON value; simpler to stream and parse than a single JSON array, at the cost of not being valid JSON as a whole file.