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 canfor awaitover — 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 withJSON.stringify(row)— never the whole collection — and writes a leading comma for every item after the first. out.write("]")thenout.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-jsonfor that direction rather than reinventing a parser. out.write()returnsfalsewhen the writable's internal buffer is full; this snippet ignores that for brevity. Under real backpressure, either await a'drain'event or run this throughpipeline()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 topipe()it rather than write to aWritabledirectly.- 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.