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.
Step-by-Step Fix
- Measure the peak. Record the operation in DevTools → Performance with Memory ticked (browser) or sample
process.memoryUsage().heapUsedat 10 ms intervals around the call (Node). Verification: you know the baseline, the peak and the post-GC level in MB. - Parse straight from the response. Use
await res.json()instead ofres.text()plusJSON.parse, and never store the raw text. Verification: no variable holds the text after parsing; snapshots show no large(string)retained. - 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.
- Trim during parsing. If the payload cannot change, pass a reviver to
JSON.parsethat discards unused fields, or map records to compact objects immediately and drop the original graph. Verification: retained size after GC matches the compact representation. - 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.
- 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.
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.
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.
Related
- Object Shapes, Strings and Collection Memory Costs — the parent topic
- How Async Functions and Generators Keep Frames on the Heap — keeping parsed payloads out of suspended frames
- Transferring ArrayBuffers vs Copying Between Workers — moving parse results without copies
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview