JSON.parse Memory Spikes on Large Payloads

A report endpoint returns 80 MB of JSON, and every time it is parsed the heap jumps by 400 MB, garbage collection stalls the page or server for hundreds of milliseconds, and occasionally the process runs out of memory. This guide from Object Shapes, Strings and Collection Memory Costs, in JavaScript Memory Fundamentals & Runtime Mechanics, explains where the memory of a JSON.parse call goes, why the peak is so much larger than the payload, and how to cut it.

Symptom Root Cause Immediate Action Measurable Impact
Heap spikes to 4–6× the payload size during parsing Raw text + parsed object graph + transient buffers alive together Measure peak; stream or reduce payload Peak close to the size of the data you keep
Long GC pause right after parsing Millions of new objects promoted at once Parse incrementally or in a worker Main thread stays responsive
OOM when several large responses arrive together Concurrent parses multiply the peak Limit concurrency of large parses Peak bounded by concurrency × payload
Only a few fields are used from each record Full object graph built for data you discard Use a reviver that drops fields, or request fewer fields Smaller retained graph
Raw text kept after parsing res.text() result stored or captured Use res.json() or drop the text reference Text memory released immediately

Root Cause: Three Copies of the Data at Once

Parsing JSON transforms text into an object graph, and for a while both exist. Consider an 80 MB response. First, the response body is read into a string: 80 MB if it is ASCII (Latin-1 strings use one byte per character in V8), up to 160 MB if it contains characters that force two-byte storage. Then JSON.parse walks the text and allocates the result: every object, array, property name and string value becomes a heap object. Object graphs are much larger than their JSON text — each object has a header and hidden class pointer, each array a backing store, each number may be a heap number, and property values that are strings become separate string objects. A parsed graph of two to three times the text size is common for record-shaped data. During parsing V8 also uses internal scratch structures.

So the peak is roughly text + graph + scratch, which for the 80 MB example easily exceeds 300–400 MB, even though the application may only keep a fraction of the data. After the parse, the raw text becomes garbage (unless you keep it), and the graph — freshly allocated, all at once — is promoted from the young generation to old space in bulk, which is why a major GC pause often follows a big parse, as seen in spotting garbage collection pauses in a performance trace.

Several habits make it worse. Reading the body with res.text() and then calling JSON.parse keeps the text in a variable longer than necessary; storing it “for debugging” keeps it forever. Extracting fields with string operations instead of parsing can leave sliced strings retaining the parent text. Parsing several large responses concurrently multiplies the peak. And building the entire graph when only three fields per record are needed wastes most of the allocation.

Heap during an 80 MB JSON.parse Before the response arrives the heap is at 120 megabytes. Reading the body as text adds 80 megabytes. During JSON.parse the parsed graph grows to about 220 megabytes while the text is still alive, producing a peak near 440 megabytes. After parsing, the text becomes garbage and a garbage collection brings the heap to about 340 megabytes, which is the baseline plus the kept graph. 450 MB 0 baseline + text 80 MB peak: text + graph after GC: graph kept time: fetch → text → JSON.parse → GC

Step-by-Step Fix

  1. Measure the peak. Record the operation in DevTools → Performance with Memory ticked (browser) or sample process.memoryUsage().heapUsed at 10 ms intervals around the call (Node). Verification: you know the baseline, the peak and the post-GC level in MB.
  2. Parse straight from the response. Use await res.json() instead of res.text() plus JSON.parse, and never store the raw text. Verification: no variable holds the text after parsing; snapshots show no large (string) retained.
  3. Ask for less data. Add server-side field selection or pagination so responses contain only what the client uses. Verification: payload size and parsed graph size both drop.
  4. Trim during parsing. If the payload cannot change, pass a reviver to JSON.parse that discards unused fields, or map records to compact objects immediately and drop the original graph. Verification: retained size after GC matches the compact representation.
  5. Stream very large payloads. For newline-delimited JSON or large arrays, parse incrementally so only one record (or a small batch) exists at a time. Verification: peak memory stays near baseline plus the batch size, regardless of total payload.
  6. Move parsing off the main thread and limit concurrency. Parse in a worker (browser or Node worker_threads) and allow only one or two large parses at a time. Verification: main-thread long tasks disappear and the process peak is bounded.
Peak heap by strategy for an 80 MB payload Text then JSON.parse with the text kept: about 440 megabytes peak. res.json with no text kept: about 400 megabytes. JSON.parse with a reviver keeping three fields per record: about 260 megabytes. Streaming NDJSON records into compact objects: about 150 megabytes. Peak heap, 80 MB payload (baseline 120 MB) text() + parse, text kept ~440 MB res.json() ~400 MB reviver keeps 3 fields ~260 MB streamed NDJSON ~150 MB

Command and Code Reference

Use case: a reviver that keeps only the fields you use. Returning undefined from a reviver deletes the property from the result.

const KEEP = new Set(['id', 'name', 'total']);

// The reviver runs bottom-up; `this` is the object or array holding `key`
function compactReviver(key, value) {
  // keep the root, array elements (the records) and the fields we read
  if (key === '' || Array.isArray(this) || KEEP.has(key)) return value;
  return undefined;                  // any other named field is dropped
}

const text = await res.text();
const rows = JSON.parse(text, compactReviver); // text is still transiently alive here

Use case: stream newline-delimited JSON in Node.js. Each line is parsed and reduced to a compact record; the peak stays near one batch.

// ndjson.mjs — process a large NDJSON response line by line
import { createInterface } from 'node:readline';
import { Readable } from 'node:stream';

export async function loadRows(url) {
  const res = await fetch(url);
  const lines = createInterface({ input: Readable.fromWeb(res.body) });
  const ids = new Int32Array(1_000_000);           // compact storage for the kept field
  let n = 0;
  for await (const line of lines) {
    if (!line) continue;
    const rec = JSON.parse(line);                   // one small object at a time
    ids[n++] = rec.id;                              // keep only what we need
  }
  return ids.subarray(0, n);
}

Use case: parse in a worker so the main thread never holds the text.

// parse-worker.js (browser)
self.onmessage = async ({ data: url }) => {
  const rows = await (await fetch(url)).json();     // text and graph live in the worker
  const compact = rows.map((r) => ({ id: r.id, total: r.total }));
  self.postMessage(compact);                        // only the compact result crosses over
};

Verification and Regression Prevention

Re-measure the same operation with the same payload: the peak should approach baseline plus the size of the data you keep, the post-GC heap should equal baseline plus the compact representation, and major GC pauses after the operation should shrink. In the browser, the main thread should show no long task for parsing if you moved it to a worker.

Protect against regressions with payload-size budgets on the API (alert when a response grows beyond a threshold) and a test that parses a production-sized fixture while asserting peak heap stays within a budget. When the payload is unavoidable, stream by default; the related streaming patterns for servers are in async iterators and stream backpressure.

Heap during one large payload Processing the same large payload, parsing the whole string at once produces a tall spike: the raw string plus the full object graph are alive together. After streaming or keeping only a compact representation, the peak approaches baseline plus the data you keep, and major GC pauses after the operation shrink. heap one payload, start → end JSON.parse on the whole string streamed / compact result

Edge Cases and Gotchas

Two-byte strings double the text cost

If the payload contains characters outside Latin-1 (many emoji or CJK text), V8 stores the whole string in two-byte form, doubling the text’s memory. Budget for it when payloads contain such content.

Revivers are slow for huge inputs

A reviver is called for every key and value, which adds CPU time proportional to the payload. It saves memory in the retained graph but does not reduce the peak from the text. For very large payloads, streaming beats revivers.

structuredClone and postMessage copy

Sending a large parsed graph from a worker to the main thread copies it again via structured clone. Send compact results, or transfer typed arrays, rather than the full graph.

Response.json() still reads the whole body

res.json() avoids exposing the text to your code, but the browser or runtime still reads the full body before parsing. It reduces how long the text lives, not whether it exists.

Frequently Asked Questions

How much memory does JSON.parse need?

Roughly the size of the text plus the size of the resulting object graph, which for record-shaped data is often two to three times the text, plus transient overhead. The peak can therefore be four to six times the payload size while both text and graph are alive.

Is there a streaming JSON.parse?

Not built in. You can stream newline-delimited JSON line by line with standard APIs, or use a streaming JSON parser library for large single documents. Changing the API to paginate or to emit NDJSON is often the simplest solution.

Does parsing in a worker reduce memory?

It moves the peak to the worker’s heap and keeps the main thread responsive, but total process memory still includes it. The real reduction comes from sending only a compact result back and from limiting concurrent large parses.

Why is there a long GC pause after parsing?

The parse allocates the whole object graph in a short burst. Those objects survive scavenges and are promoted to old space together, which triggers or lengthens major collections shortly afterwards. Parsing incrementally spreads that work out.