The JSON Memory Ledger: What Strings, Objects, and Big Arrays Actually Cost
Bottom line: A 10MB JSON file can become 30-50MB in the V8 heap. Learn what strings, objects and numbers really cost, how to baseline with process.memoryUsage, and when streaming helps.

Bottom line: baseline with process.memoryUsage first, recording the text and the object graph as two separate increments, then remove one full copy before trying anything else — no JSON.parse(JSON.stringify(x)), no reactive wrapping of the raw document. Cut long-lived references next, and consider streaming last: its flat memory curve is worth losing random access only when the file does not fit or you need just a few fields.
A 10MB JSON document can become 30MB to 50MB in the heap after
JSON.parse, and the transient peak goes higher still. Without doing this arithmetic, "optimization" is guesswork.
1. Text and the object graph are two different ledgers
On disk and in a request body, JSON is a sequence of bytes. Once it enters memory, it becomes two things: a string, and an object graph made of objects, arrays, numbers, and booleans. Keeping those two ledgers separate is the only way to explain why a 10MB file can make a process consume 80MB.
| Data shape | Object graph vs. text size | Dominant cost |
|---|---|---|
| Flat array of objects (about 8 fields each) | Roughly 2.5-4x | One object header and property slots per element |
| Deeply nested configuration | Roughly 3-5x | Many small objects, each with its own header |
| Array of very long strings | Roughly 1.2-2x | String headers plus character data |
| Large numbers with decimals | Roughly 2-3x | One HeapNumber per value |
| Documents with many repeated keys | Proportional to key count | Key hashing and property setup during parsing |
These multipliers are order-of-magnitude references that depend on the V8 version, pointer compression, field count, and string contents. The table makes one point: the object graph is sized by "node count x per-node overhead," not by the number of bytes in the text.
2. Strings: the most underestimated line item
A V8 string is not a bare character array. It carries an object header (a map pointer, a hash field, and a length), which costs roughly a dozen bytes on a typical 64-bit build with pointer compression, before any characters. Characters are stored in one of two ways:
- One-byte strings for characters in the Latin-1 range: 1 byte per character;
- Two-byte strings when the content includes CJK characters, emoji, or anything outside Latin-1: 2 bytes per character.
A Chinese character occupies 3 bytes in a UTF-8 file but 2 bytes per character in a two-byte string, plus the header. Text size and heap size are not the same measurement, and they do not always move in the same direction.
Three details matter directly for JSON work:
- Slices keep the parent alive. V8 represents longer substrings as
SlicedString, which stores a parent reference plus an offset and a length. Store 200 characters sliced from an 80MB log and the entire 80MB stays reachable until that slice is released. - Concatenation creates
ConsString. Repeated+=on large strings builds a tree of fragments that is only flattened into contiguous memory when characters are actually read. Concatenating ten thousand times and then reading once produces a large allocation spike at that moment. - Repeated values are not automatically shared. Ten thousand records containing
"status": "paid"usually get ten thousand independent string objects; the parser does not point them at one constant. To save that cost you have to map frequent strings onto enums or numbers yourself.
When you need to keep a short excerpt for a long time, turn it into an independent string and release the original:
// Bad: the slice references the whole document, so logText cannot be collected
const summary = logText.slice(0, 200);
// Good: create an independent string, then release logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. Objects: every key occupies a slot
A plain object contains at least three things: a pointer to its hidden class (map), a pointer to its property store, and a pointer to its element store. With pointer compression each pointer is 4 bytes, and the heap object header adds more, so even an empty object costs roughly sixteen to twenty-something bytes. On a 64-bit build without pointer compression, double that.
Named properties land in one of two places:
- In-object properties: slots laid out directly after the object header, which gives the fastest access;
- A
PropertyArray: once the in-object slots are exhausted, properties move to a separate array and the object holds a pointer to it.
Each slot is 4 or 8 bytes, and the key string is accounted for separately. That is why long field names become a real cost across hundreds of thousands of records, not just an aesthetic issue.
The good news is that hidden classes are shared: a million objects with the same keys in the same order share one map instead of copying structural information. The bad news is that these patterns break that sharing or push the object into a more expensive representation:
- Some records have a
remarkfield and others do not, producing two shapes and an extra shape check on every access; - Different insertion orders can produce different shapes;
- Very large property counts (beyond roughly one thousand) or frequent
deleteoperations push the object into dictionary mode, where every property also carries a hash-table entry; Object.create(null)is in dictionary mode from birth. It is a good lookup table and a poor choice for a fixed-schema record accessed in a hot loop.
One common misconception deserves a correction: JSON permits duplicate keys, and {"a":1,"a":2} is a valid document whose result is a === 2. The final object keeps one value, but the parser still scans and assigns every occurrence. Duplicate keys do not save memory; they only cost parse time.
4. Arrays and numbers: element kinds set the width
V8 tracks several "element kinds" for arrays, and each has a different per-slot width:
| Array contents | Element kind | Approximate cost per slot |
|---|---|---|
| Small integers (within Smi range) | PACKED_SMI_ELEMENTS |
4 bytes |
| Decimals or very large integers | PACKED_DOUBLE_ELEMENTS |
8 bytes |
| Strings or objects | PACKED_ELEMENTS |
4-byte pointer plus the value itself |
Arrays with delete-created holes |
HOLEY_* |
Same, plus hole checks |
| Very sparse arrays | DICTIONARY_ELEMENTS |
Hash entries, typically 20+ bytes |
Two rules are worth memorizing:
First, element kinds only degrade. An array that starts with small integers becomes a double array the moment you insert 0.5, and a generic element array when you insert a string. It never automatically recovers; you have to build a new array.
Second, where a JSON number lands depends on its value. Integers within Smi range (roughly plus or minus 1.07 billion) are stored directly as a Smi and occupy one pointer slot. Numbers with decimals or exponents, or values outside that range, allocate a HeapNumber of about 16 bytes, with a pointer to it stored in the array. So [1, 2, 3] and [0.1, 0.2, 0.3] differ not merely in value but in "one 4-byte slot" versus "an 8-byte slot plus a heap object."
This is also the root of the large-integer ID precision problem: beyond the safe integer range, JSON numbers are treated as doubles and low-order digits simply disappear. JSON arrays arrive packed and hole-free, so do not delete elements after parsing; that pushes the whole array toward a more expensive representation.
5. Parse and serialize peaks: the moment a process actually dies
Steady-state memory is one ledger; the peak is another. While JSON.parse(text) runs, the process holds three things at once:
- The input string
text; - The object graph being built and not yet complete;
- The parser's own temporary structures.
The parse peak is roughly "text + object graph + temporary overhead." After parsing, if text is still referenced by a variable, a closure, a module-level cache, or a reactive store, that text memory is never returned.
Serialization has a peak too. JSON.stringify(data) holds the full object graph while building the output string, with concatenation buffers in between. When the output approaches V8's string length limit (around 500 million characters), it throws a RangeError; this failure usually shows up in scripts that export an entire database as one JSON document.
The most expensive common pattern uses serialization as a deep clone:
// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));
That single line stores the same data three times over. For read-only work, pass a reference. When isolation is genuinely required, use structuredClone and accept that it also rebuilds the entire object graph.
6. Measuring with process.memoryUsage
In Node, process.memoryUsage() returns five numbers at once: rss (physical memory used by the process), heapTotal, heapUsed (used JS heap), external (memory held by C++ objects), and arrayBuffers (ArrayBuffer and Buffer memory). When studying JSON handling, heapUsed is the right lens for the object graph and rss tells you whether the whole process is out of control.
A measurement script needs three disciplines: call global.gc() first, sample in stages, and change one variable at a time. This script records the increments for "read the text" and "parse into objects" separately:
// Run: node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';
global.gc();
const baseline = process.memoryUsage().heapUsed;
const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;
const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;
console.log({
textOnHeap: afterRead - baseline,
graphOnHeap: afterParse - afterRead,
parsePeak: afterParse - baseline,
});
A few traps:
--expose-gcmust be enabled explicitly; otherwiseglobal.gcdoes not exist;heapUsedcovers only the JS heap. File-reading Buffers live underexternalandarrayBuffers, so do not use one number to explain everything;- Do not
console.loga large object between samples; it keeps a temporary reference alive; - The first run includes JIT warm-up and lazy compilation. Run at least three rounds and take the median;
- To compare in a browser, take heap snapshots in the DevTools Memory panel and read
Retained Sizerather than staring at the sawtooth in the Allocation Timeline.
Without this baseline, claims like "switching to streaming will fix it" or "a cache will make it faster" are just feelings.
7. Streaming buys peak memory, not total time
The trade in streaming parsing is explicit: random access in exchange for memory that no longer scales with file size.
| Approach | Peak memory | Random access | Best for |
|---|---|---|---|
Full JSON.parse |
Text plus the complete object graph | Yes | Under roughly 100MB and most fields are needed |
A SAX parser such as stream-json |
One chunk plus the current element | No | Huge files, or only a few fields are needed |
| Line-by-line JSON Lines | The object for one line | No | Data that is naturally a batch of records |
| External index or database | Nothing loaded in full | Yes | Repeated queries over the same data |
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';
const pipeline = createReadStream('huge.json')
.pipe(parser())
.pipe(pick({ filter: 'items' }))
.pipe(streamArray());
for await (const { value } of pipeline) {
handleItem(value);
}
This code never builds the full tree, so the memory curve stays flat. The costs are real too: you cannot go back for a field you skipped, global aggregations become hard, error recovery is your responsibility, and a pure-JS character-by-character scan is usually an order of magnitude slower than native JSON.parse.
The decision is not "did the file exceed 10MB." Ask two questions: Do I need most of the fields after parsing? Can I live with sequential access? If both answers are yes, full parsing is correct and your effort belongs in reducing copies and rendering. If either answer is no, consider streaming. For the complete layered strategy, see Parsing, Rendering, and Streaming Large JSON.
8. Fewer copies, earlier release
In memory work, removing one full copy is often worth more than swapping parsers:
- Pass references for read-only work. Do not deep-clone "for safety," especially not with
JSON.parse(JSON.stringify(x)); - Do not wrap the raw document in a reactive system. Vue's
reactiveproxies the entire object graph recursively, and the proxy layer has its own memory cost; - Compute and cache derived data separately. If a list only needs id, title, and status, do not put whole records into the store;
- Normalize high-frequency strings. Map values such as
"paid"and"pending"onto numeric enums to save both string objects and comparison cost; - Cut long-lived references. Null out large objects when done, clear caches, and unregister callbacks so closures do not carry them into the next page;
- Account for Worker copy costs.
postMessageuses structured clone by default; sending a 50MB object graph back to the main thread builds a second copy. Return a summary whenever possible.
The other half of the size ledger lives in Stop Doing JSON Compression in the Wrong Order: disk bytes and wire bytes are a different account from the in-heap object graph, and no single percentage explains both. For how editors and CLI tools behave with memory, see Editors, Command-Line Tools, and Automation.
9. Checklist
| Symptom | More likely cause | First action |
|---|---|---|
| A 10MB file pushes the process to 50MB | Object graph and text both stay alive | Measure in stages; check whether the text is retained |
| OOM at parse time, normal at steady state | The parse peak overlaps old data | Release old objects before parsing the new file |
| Memory climbs slowly and never drops | A slice, cache, or closure holds a reference | Take a heap snapshot and sort by retained size |
| Arrays are larger than expected | A decimal degraded the element kind | Unify numeric types; avoid delete |
| Adding a cache made things slower | Keys and values consume memory, raising GC pressure | Bound the cache and store summaries only |
Why does memory grow three or four times after JSON.parse?
Because the text is only the input; the parse product is an object graph. Every object has a header, every property has a slot, and every string has its own header. With enough nodes, total overhead exceeds the byte count of the text. The multiplier depends on data shape, and flat object arrays differ greatly from deeply nested documents.
Should a 10MB JSON file be parsed in full or streamed?
Look at the purpose first. If you need random access, need most fields, and the file fits in memory, use full JSON.parse. If you need only a few fields, or the file does not fit, stream it. 10MB is not a boundary; streaming usually becomes mandatory somewhere above 100MB.
Why does process.memoryUsage().heapUsed disagree with system monitoring?
heapUsed covers only the used portion of the JS heap, excluding external, arrayBuffers, and code space. System monitoring reports rss, which is a different measurement. Track heapUsed and rss together; a large gap points to Buffers and native objects.
Is JSON.parse(JSON.stringify(x)) a safe deep clone?
No. It holds the original graph, temporary text, and new graph at the same time, so the peak is roughly three times steady state, and it drops undefined, functions, and Date semantics. Pass references for read-only work; when isolation is required, use structuredClone and accept the same rebuild cost.