Object Shapes, Strings and Collection Memory Costs
A heap that grows without leaking is usually paying overhead rather than storing data, and this guide inside JavaScript Memory Fundamentals & Runtime Mechanics works out where those bytes go. The same V8 heap layout that makes allocation cheap also gives every object a header, every property a slot and every collection entry a bucket, and at a million instances those constants decide whether your working set fits in a phone’s budget.
Conceptual Grounding: Shapes, Slots and Backing Stores
Every V8 object begins with a pointer to a map — the engine’s name for a hidden class describing the object’s layout. The map is shared: a million objects created from the same literal with the same property order share one map, and each instance pays only the pointer. Add a property to one instance and V8 transitions it to a new map, which is cheap once and expensive if it happens on a hot path with many distinct shapes, because each shape allocates its own map object in map space.
Properties themselves live in one of two places. The first few — up to the in-object limit V8 computes from the constructor — are stored inline in the object body, four bytes each with pointer compression enabled. Beyond that, properties spill into a separate backing store, which is a second allocation with its own header. Delete a property and V8 may demote the object to dictionary mode, replacing the shared-map fast path with a per-object hash table that roughly doubles the per-property cost and disables inline caching.
Strings have their own layer of representations. A short literal is a sequential string with a header and its character data inline. Concatenation usually produces a cons string — a small node pointing at two halves — so a + b is fast and retains both operands. Slicing produces a sliced string that points into its parent, which is why holding a ten-character substring of a two-megabyte response can retain the whole two megabytes. Flattening, forced by operations that need contiguous characters such as a regular expression match, allocates a fresh buffer for the entire result.
Diagnostic Workflow
- Split the heap by category first. DevTools path: Memory → Heap snapshot → Statistics. Expected: a pie of code, strings, JS arrays, typed arrays and “other”. A strings slice above 40% points at the string section below; a large “other” points at object overhead.
- Compute bytes per instance for your top constructor. DevTools path: Summary view, sort by Shallow Size, divide by the Objects count. Expected: a plain data object with five small fields should land near 60–80 bytes; anything above 200 means spilled properties, dictionary mode, or fields you forgot are there.
- Look for shape churn. Command:
node --expose-gc --trace-maps script.js, or in the browser compare map-space size between two snapshots. Expected: a stable app creates a few thousand maps; tens of thousands means objects are being built with inconsistent shapes. - Check string representation on the biggest strings. DevTools path: Summary →
(string)→ sort by retained size, then inspect the retainer of the largest. Expected: a sliced string whose retained size dwarfs its own length is holding its parent alive. - Measure the collection, not the contents. Command: allocate one million entries in each candidate structure under
--expose-gcand readprocess.memoryUsage().heapUsedbefore and after. Expected: a per-entry figure you can multiply by your real cardinality, which is the only number that should drive the choice.
Code Patterns & Signatures
Measuring the true per-instance cost of a shape, including headers and any spilled backing store. Run with node --expose-gc.
// Allocate a large, uniform population and divide — single objects are
// too small to measure against heap noise.
function bytesPerInstance(factory, count = 200_000) {
global.gc();
const before = process.memoryUsage().heapUsed;
const keep = new Array(count); // keep them alive during measurement
for (let i = 0; i < count; i++) keep[i] = factory(i);
global.gc();
const after = process.memoryUsage().heapUsed;
// Subtract the array's own 8 bytes-per-slot pointer cost.
return (after - before) / count - 8;
}
const flat = bytesPerInstance((i) => ({ id: i, name: 'n', email: 'e' }));
const spilled = bytesPerInstance((i) => {
const o = { id: i, name: 'n', email: 'e' };
o.tags = null; // pushes past the in-object limit
return o;
});
console.log({ flat, spilled }); // e.g. { flat: 56, spilled: 96 }
The shape-stability pattern: build every object with the same keys in the same order, and use null rather than a missing key, so V8 keeps one map for the whole population.
// BAD: three different shapes, three maps, no inline-cache reuse.
function makeUserBad(row) {
const u = { id: row.id };
if (row.name) u.name = row.name; // conditional key → new shape
if (row.email) u.email = row.email; // another shape
return u;
}
// GOOD: one shape for every row, regardless of which fields are present.
function makeUserGood(row) {
return {
id: row.id,
name: row.name ?? null, // key always present
email: row.email ?? null,
};
}
Releasing a parent string that a small slice is retaining. This is the single most common “the heap is full of strings” cause.
const body = await response.text(); // 2.4 MB
// A slice keeps a pointer to the parent, so `body` cannot be collected:
const leakyId = body.slice(1200, 1236);
// Forcing a fresh, independent buffer detaches it from the parent.
// Any operation that must produce contiguous characters works; the
// simplest explicit form is a template concatenation with an empty string.
const safeId = `${body.slice(1200, 1236)}`.normalize();
// After this, `body` is collectable and 2.4 MB comes back.
Measuring Before You Optimise
Almost everything in this guide is a constant factor, and constant factors only matter at scale. Restructuring a hundred objects to share a shape saves a few kilobytes and costs readability; restructuring a hundred thousand saves tens of megabytes and is often the difference between fitting a phone’s budget and not. The decision therefore belongs after a measurement, not before one, and the measurement is cheap enough that there is no excuse for guessing.
The harness that answers it is the bytesPerInstance function above, run against the two candidate representations with the real cardinality of your data. Two rules make its output trustworthy. First, allocate a large population — two hundred thousand instances is a good default — because a single object is far below the noise floor of heapUsed. Second, force a collection on both sides of the measurement, or the figure includes whatever short-lived garbage the allocation loop produced along the way.
Read the result as a ratio rather than an absolute. Knowing that a record costs 96 bytes is less useful than knowing it costs 1.7 times the flat alternative, because the ratio is what survives a V8 upgrade, a pointer-compression change or a move from a desktop to a mobile build. When the ratio is under about 1.2 the restructuring is rarely worth it; above 2 it usually is, particularly for data that lives for the whole session.
There is one measurement that pays for itself regardless of scale: the per-entry cost of whatever collection type your hottest cache uses. A cache is by definition long-lived and by definition large, so a six-fold difference between a Map of objects and a string-keyed plain object is not a constant factor you can dismiss — it is the difference between 24 MB and 152 MB at a million entries, on a device that may only have a few hundred megabytes to give.
Symptom-to-Fix Reference Table
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Strings dominate the Statistics view | Sliced strings retaining large parents | Copy the slice into a fresh string before storing it | Frees the parent — often tens of MB per response |
| Map space grows steadily | Shape churn from conditional property assignment | Build every object with a fixed key set, using null for absent values |
Map count drops to the number of real shapes |
| Per-instance cost above 200 bytes for a small record | Properties spilled past the in-object limit | Reduce to the fields actually read, or store columns in parallel arrays | 40–60% smaller per instance |
Object gets slower and larger after delete |
Demotion to dictionary mode | Assign undefined instead of deleting, or rebuild the object |
Restores fast-mode layout and inline caches |
A Map of small objects costs 150+ bytes per entry |
Three allocations per logical row | Flatten to parallel typed arrays or a single string-keyed object | Up to 6× reduction at the same cardinality |
| Large arrays of numbers cost 8× the expected size | Boxed values in a generic elements store | Use a typed array such as Float64Array |
Exactly 8 bytes per element, no header per value |
Edge Cases & Gotchas
Pointer compression halves pointers, not payloads. On a 64-bit build with compression enabled, object references occupy four bytes rather than eight, so per-object overhead is meaningfully lower than older measurements suggest — but only for pointers. Numbers stored as doubles and string character data are unaffected, so a heap dominated by data rather than structure sees almost no benefit.
Numbers are not uniformly cheap. Small integers are stored inline as tagged values with no allocation at all. A double that does not fit that representation is boxed into a HeapNumber — an allocation of its own — so an array of floating-point values in a generic elements store can cost eight times what the same values cost in a Float64Array.
Property order matters more than property count. {a, b} and {b, a} are two different shapes with two different maps. A parser that emits object keys in the order they appear in the input will produce a new shape for every ordering variation in the data, which is one of the few ways to genuinely explode map space in a small program.
Array holes are contagious. Deleting an element or assigning past the end converts a packed elements store into a holey one, which costs a slower lookup path and, for large sparse arrays, a dictionary representation with roughly triple the per-element overhead. Build arrays with push or a fixed-length constructor and fill them completely.
A WeakMap is not free. It avoids retention, which is usually the point — see WeakMap vs WeakRef vs FinalizationRegistry — but each entry still costs on the order of a Map entry while it lives, and the collector has extra work to do at every major collection.
FAQ
How much memory does an empty JavaScript object use?
About 32 bytes on a 64-bit V8 build with pointer compression enabled: a map pointer, a properties backing-store pointer, an elements pointer and header padding. Adding in-object properties grows it by 4 bytes each up to the in-object limit, after which properties move to a separate backing store that costs another allocation.
Does concatenating strings copy the whole string every time?
No. V8 usually builds a cons string — a small node holding pointers to the two halves — so concatenation is cheap but the result retains both operands. The cost appears later: the moment anything flattens the rope, V8 allocates a contiguous buffer for the entire result, which can be many times the size of the piece you actually needed.
Is a Map always more memory-efficient than a plain object?
No. A Map costs roughly 48 bytes per entry against about 24 for a dictionary-mode object property, so for pure string-keyed storage an object is smaller. Map wins on deletion behaviour, non-string keys and predictable insertion-order iteration, not on raw bytes.
What is a hidden class and why does it affect memory?
A hidden class, or map, is V8’s shared description of an object’s layout. Objects with identical shapes share one map, so the per-object cost is just the pointer. Building objects with different property orders creates a separate map per shape, and those maps live in their own heap space — which is why a growing map space usually means shape churn rather than more data.
Related
- How Hidden Classes and Inline Caches Affect Memory — shape transitions in detail, with
--trace-mapsoutput - Why String Concatenation Inflates V8 Heap Usage — cons strings, flattening and slice retention
- Map vs Object vs Array Memory Overhead — the full per-entry measurement harness
- Understanding the V8 Heap Layout and Memory Segments — where maps, strings and backing stores actually live