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.

What one entry costs in each container A Map entry is a key slot, a value slot and chaining metadata totalling about forty-eight bytes. A dictionary-mode object property is about twenty-four. A packed array slot is eight bytes plus any boxed value. A typed array element is exactly eight bytes with no header at all. One logical entry, four containers — the tax before your data is counted Map entry · ~48 B key slot value slot bucket/chain ordered iteration and any key type are what you buy Dictionary object property · ~24 B hash entry value string keys only, and deletion degrades the layout Packed array slot · 8 B + boxing small integers are inline and free; arbitrary doubles may allocate a HeapNumber Typed array element · exactly 8 B contiguous backing store, no per-value header, and the only container you can size on paper

Step-by-Step Fix

  1. Get the real cardinality. Command: log the container’s size or 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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;
One million numeric records, five storage layouts Storing a million records of three numbers each: a Map of objects uses one hundred and fifty-two megabytes, an array of objects one hundred and four, a plain object of arrays sixty-eight, parallel typed arrays with a Map index forty, and pure typed arrays with an integer index twenty-four. Same million records of three numbers, five layouts Map of objects 152 MB array of objects 104 MB object of arrays 68 MB typed arrays + Map index 40 MB typed arrays + integer index 24 MB The bottom two give up ergonomics for a 4–6× reduction; the top two are what most code ships with.

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.

Choosing the container in three questions If the values are numeric and the keys can be integers, use typed arrays. Otherwise, if the keys are strings and deletion is rare, use a plain object as a dictionary. Otherwise use a Map, and accept its higher per-entry cost as the price of its semantics. At high cardinality, answer these in order Numeric values, integer keys? Yes → typed arrays 8 B/element, no header, and a size you can compute No → string keys, rare deletes? plain object as a dictionary, about half a Map's cost Otherwise → Map pay 48 B/entry for object keys, order and clean deletion Below about ten thousand entries, ignore all of this and pick whichever reads best.

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.