Map vs Object vs Array Memory Overhead in JavaScript
Container choice is usually made on ergonomics, which is correct until the cardinality reaches six figures — at which point the per-entry overhead becomes the dominant term and the choice is a memory decision. This page belongs to object, string and collection memory costs in JavaScript Memory Fundamentals & Runtime Mechanics and gives measured numbers plus a rule for using them.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| A million-entry lookup costs 150 MB | Map of small objects — three allocations per row |
Flatten to a string-keyed object or parallel arrays |
| An array of numbers costs 8× the expected size | Doubles boxed into individual heap numbers | Switch to Float64Array or Int32Array |
| Heap grows after many deletions | Object demoted to dictionary mode, or array made holey | Rebuild the container, or use Map if deletion is frequent |
A Set of ids is larger than the ids |
Per-entry bucket overhead dominates short strings | Use an object as a dictionary, or hash into a typed array |
| Memory fine locally, large in production | Cardinality in production is orders of magnitude higher | Measure at the 95th-percentile size, not the average |
Root Cause: Every Container Has a Per-Entry Tax
None of these structures stores only your data. A Map maintains a hash table with buckets, and each entry carries a key slot, a value slot and chaining metadata — around 48 bytes per entry in current V8, independent of what the key and value are. A Set is the same structure without the value slot, around 36 to 40. A plain object with many keys drops out of fast mode into dictionary mode, where each property costs roughly 24 bytes of hash-table entry plus the value.
Arrays are different again. A packed array of small integers is genuinely compact, because those values are stored inline as tagged values with no separate allocation. A packed array of doubles is stored in a specialised double-elements store, also compact. But an array that has been made holey — by deleting an element, assigning past its end, or constructing it with new Array(n) and filling sparsely — falls back to a generic or dictionary elements store, where each element carries its own overhead.
Typed arrays remove the tax entirely for numeric data. A Float64Array is a header plus exactly eight bytes per element in a contiguous backing store outside the JavaScript heap, which is why it is the only container whose memory you can compute exactly.
The practical consequence is that nesting multiplies. A Map of small objects pays the Map entry (48), the object header and slots (roughly 56 for two fields), and any strings the object holds — about 150 bytes for what is logically two numbers and a name.
Step-by-Step Fix
- Get the real cardinality. Command: log the container’s
sizeor key count at the end of long sessions and take the 95th percentile. Expected: a number, not an intuition — this is the term everything else multiplies. - Measure the candidates at that size. Command: the harness below under
node --expose-gc. Expected: bytes-per-entry figures within a few per cent across repeated runs. - List the behaviour you actually need. Non-string keys? Ordered iteration? Frequent deletion? Expected: most lookup tables need none of the three, which makes the cheapest option viable.
- Switch and re-measure in the real application. Verification: a heap snapshot shows the predicted drop; a microbenchmark saving that does not appear in production usually means the entries themselves, not the container, dominate.
- Record the figure next to the structure. Verification: the next person to add a field to the entry can see what it costs at your cardinality.
Command & Code Reference
The measurement harness. Everything else in this page came out of it.
// node --expose-gc collections.js
function measure(label, build, n = 1_000_000) {
global.gc();
const before = process.memoryUsage().heapUsed;
const container = build(n); // kept alive across the measurement
global.gc();
const after = process.memoryUsage().heapUsed;
const perEntry = (after - before) / n;
console.log(`${label.padEnd(28)} ${perEntry.toFixed(1)} B/entry`);
return container; // returned so it cannot be collected early
}
measure('object, string keys', (n) => {
const o = Object.create(null);
for (let i = 0; i < n; i++) o['k' + i] = i;
return o;
});
measure('Map, string keys', (n) => {
const m = new Map();
for (let i = 0; i < n; i++) m.set('k' + i, i);
return m;
});
measure('array of [k, v]', (n) => {
const a = new Array(n);
for (let i = 0; i < n; i++) a[i] = ['k' + i, i];
return a;
});
measure('Float64Array', (n) => {
const f = new Float64Array(n);
for (let i = 0; i < n; i++) f[i] = i;
return f;
});
The flattening pattern: replacing a Map of objects with parallel typed arrays plus one index.
// Before: 1 000 000 entries × ~152 B = 152 MB
// const rows = new Map(); rows.set(id, { price, qty, updatedAt });
// After: three typed arrays plus one index — ~40 MB for the same data.
function createRowStore(capacity) {
const price = new Float64Array(capacity);
const qty = new Int32Array(capacity);
const updatedAt = new Float64Array(capacity);
const indexOf = new Map(); // id → slot; the only per-entry object cost
let next = 0;
return {
set(id, row) {
const slot = indexOf.get(id) ?? (indexOf.set(id, next), next++);
price[slot] = row.price;
qty[slot] = row.qty;
updatedAt[slot] = row.updatedAt;
},
get(id) {
const slot = indexOf.get(id);
if (slot === undefined) return undefined;
// Rebuilt on read: the object exists only as long as the caller holds it.
return { price: price[slot], qty: qty[slot], updatedAt: updatedAt[slot] };
},
};
}
Keeping arrays packed, which is worth more than most container changes.
// Holey arrays fall back to a slower, larger elements store.
const bad = new Array(1000); // holey from birth
bad[0] = 1; bad[999] = 2; // 998 holes
// Packed: allocate and fill in one pass, and never delete an element.
const good = new Array(1000).fill(0);
// Removing a value: write a sentinel rather than delete good[i].
good[42] = 0;
Verification & Regression Prevention
Verify in the application, not the benchmark. Take a heap snapshot before and after the change, find the container root, and compare retained sizes; a microbenchmark improvement that does not show up there means the entries themselves — strings, nested objects — dominate, and the container was never the problem.
To keep the gain, add a comment recording the measured per-entry cost and the cardinality it was measured at, directly above the structure’s definition. The realistic failure mode is not that someone swaps the container back; it is that someone adds a field to the entry, which at a million entries can cost more than the whole container change saved. A recorded number turns that into a visible trade rather than an invisible one.
FAQ
Which is smaller, a Map or a plain object?
For string keys, a plain object in dictionary mode is smaller — roughly 24 bytes per property against about 48 for a Map entry. Map’s overhead buys ordered iteration, keys of any type and deletion that does not degrade the container, none of which a lookup table of string keys necessarily needs.
When is a typed array worth the inconvenience?
As soon as you are storing more than a few thousand numbers. A Float64Array is exactly eight bytes per element with no per-value header, against roughly 16 to 40 bytes for the same values in a regular array once boxing is counted. At a million values that is the difference between 8 MB and 40 MB.
Do Sets cost the same as Maps?
Close to it: a Set entry costs roughly what a Map entry costs minus the value slot, on the order of 36 to 40 bytes. For membership tests over string keys at high cardinality, an object used as a dictionary or a sorted typed array of hashes is substantially smaller.
Related
- Object Shapes, Strings and Collection Memory Costs — the wider per-type cost model
- Typed Arrays, Buffers and External Memory — where typed-array bytes actually live
- How Hidden Classes and Inline Caches Affect Memory — why the entries themselves may be the real cost