Object Layout and What Memory Really Costs

Store a million points. Build them as an array of { x, y } objects and the tab’s memory climbs by tens of megabytes. Build the same million points as two flat arrays of numbers, xs and ys, and it climbs by a fraction of that, holding the exact same coordinates. Same data. The code that fills them looks nearly identical. The memory does not.

The gap is not the numbers. It is everything the engine wraps around them. Every object you make carries structure you never typed, and once you make a lot of them that invisible structure is most of your footprint. This lesson opens up a single object so you can see where the bytes go, then scales that picture up to the choices that actually move the needle: an array versus a pile of wrapper objects, a Map versus a plain object, a million small things versus one big block.

One thing up front. Exact byte counts are build-specific and they change, so I am not going to hand you a table of magic numbers to memorise. What is worth having is order-of-magnitude intuition: knowing that a wrapper object costs several times the value inside it, so you reach for the right structure when you have millions of things, and you do not waste an afternoon shaving bytes off a config object. That is the whole game here.

The bytes you did not write

Here is a plain object with two properties:

const point = { x: 2, y: 9 };

In your source that is two numbers. In the heap it is more. Every V8 object starts with a small fixed header, and for a normal object that header is three machine words: a pointer to its hidden class (the shape that says where x and y live), a pointer to its properties backing store, and a pointer to its elements backing store. Only after those three does the object’s own data begin, in what V8 calls its in-object property slots.

the object{ x: 2, y: 9 }header, three wordsmaphidden classpropertieselementsin-object slotsslot 02slot 19Shape, the hidden classx goes to slot 0y goes to slot 1proto goes to Object.prototypeproperties storeempty, shared singletonelements storeempty, shared singletonEven an empty object still carries these three header words.its stores stay shared and empty until you add data
A plain object is a three-word header (hidden class, properties, elements) followed by in-object value slots. The header points out to the shape and to the two backing stores; for a small fresh object those stores are shared empty singletons.

That last line matters more than it looks. An empty {} is not zero bytes. It has no properties, but it still has the three-word header. Its properties and elements pointers do not each allocate a fresh store, though. They point at shared, read-only empty singletons that every fresh object borrows, so an empty object really is just its header. You start paying for a backing store only once you outgrow the inline slots.

In-object versus spilled properties

The in-object slots are the fast ones. A property that lives in a slot is read by taking a fixed offset from the start of the object: check the shape, load from offset. One hop, the way a struct field works in C. But an object only has room for so many inline slots. Add more properties than V8 reserved and the extras spill into the properties backing store, a separate array the object points at. Reading a spilled property is a shape check, then follow the properties pointer, then index into that array. Two hops instead of one.

the objectsix named propertiesin-object, fixed offsetabcpropertiespoints outproperties backing storethe spilled propertiesdefread obj.ashape check, then read the slot (one hop)read obj.dshape check, follow pointer, then index (two hops)
The first few properties live in fast in-object slots, read by a fixed offset. Once they overflow, the rest spill to a separate properties backing store, which costs an extra pointer hop to reach.

How many slots does V8 reserve inline? It guesses. When a constructor runs, V8 counts the properties it sees assigned plus a small buffer for a few late additions, gives the object that many slots, and then watches. After a handful of instances have been built the same way, it decides the real count and trims the unused slots back. That trimming is called slack tracking, and its practical upshot is friendly: build objects with a constructor or a consistent literal and the properties you set in the constructor tend to land inline, fast, with a little headroom to add one or two more without spilling.

There is a slower tier below spilling, worth naming so you can avoid it. If you treat an object like a hash table, adding and delete-ing keys unpredictably, V8 gives up on a fixed shape and drops the object into dictionary mode, storing properties in an actual hash table keyed by name. Access is now a real lookup, and the object no longer shares a hidden class with its siblings. That is fine for a genuine bag of dynamic keys. It is a quiet tax if you did it by accident to objects you meant to keep uniform. And it is one more reason a Map exists, which we get to below.

Pointers, almost everywhere

Look again at what sits in a slot. Sometimes it is the value itself: a small integer (a Smi) rides right inside the tagged slot, no allocation, no indirection. But most values are not like that. A slot that holds a string holds a pointer to a string object living elsewhere on the heap. A slot that holds another object holds a pointer to that object. A floating-point number, or a whole number too big to be a Smi, gets boxed: V8 puts the raw 8 bytes in a little HeapNumber object and the slot stores a pointer to it.

one object, three fieldsid7inline SmiscoreptrnameptrHeapNumber3.14, its own heap objectStringAda, its own heap object
An inline small integer costs nothing beyond its slot. A boxed number or a string is a separate heap object, and the field only holds a pointer to it, which is where the extra allocations and pointer chases come from.

This is the mechanical reason boxed numbers and heavily-referenced structures feel expensive. The field is cheap, 4 bytes of pointer, but on the other end sits a whole separate object with its own header, and reaching the value means chasing the pointer. A Smi avoids all of that. The full story of Smis, boxing, and when V8 unboxes numbers back into flat storage is its own lesson; strings have a parallel story of one-byte and two-byte layouts and shared slices in the strings lesson. For our purposes the takeaway is the layout one: an object is mostly a bundle of pointers, and each pointer can lead to more allocated objects you are also paying to keep and to collect.

Why a million tiny objects hurt

Now scale it up, because that is where these small per-object costs turn into a real number. Say you have a million values and you want to keep them. Two ways:

// A: a million one-field wrapper objects
const wrapped = values.map((v) => ({ v }));

// B: the same values in one packed array
const packed = Int32Array.from(values);

Option A makes a million objects. Each one is a three-word header plus a slot for v, so roughly four words of object, and then the array that holds them needs a pointer to each, one more word per item. The value v you actually cared about is one of those five words. The other four are structure. Option B stores the values back to back in one contiguous block with a single header for the whole thing. Same numbers, but now almost every byte is data.

an array of a million { v } objectsarray of referencesptrptrptrptrheader x3vheader x3veach = header + value + a pointer to ita million numbers, packed314159one contiguous block, values inlineno per-item object, no pointers to chasethe same million values, memory comparedarray of { v }about 5xpacked arraybaseline, about 1xdata, the numbersoverhead, headers and pointers
The same million values, two layouts. Wrapper objects are dominated by per-object headers and the array of pointers reaching them; the value you wanted is a small slice of the total. A packed array is almost all data.

The multiplier is not exotic. It is per-object structure showing up a million times. This is the array-of-numbers-is-denser-than-array-of-wrappers rule in the elements kinds lesson, seen from the memory side rather than the speed side. Both point the same way: when a collection is large and its items are simple, a flat array (or a typed array for numbers) can be several times smaller and faster than the tidy-looking array of little objects, because it deletes the headers and the pointers.

Map and Set: paying for semantics

So should everything be a flat array? No. Sometimes you need lookup by arbitrary key, or membership tests, or insertion order, and that is what Map and Set are for (their behaviour and API are covered in Map and Set). They cost more per item than a plain object property or an array slot, and it is worth knowing why so the choice is informed rather than guilty.

Internally a Map is a hash table. Each entry needs room for the key, the value, and a link to the next entry in its bucket, so an entry is on the order of three words, and the table keeps spare capacity so it can grow without rehashing on every insert, which adds more slack on top. A Set is the same machine without the value word. Compare that to a packed array element (one word, the value, inline) or an in-object property (one word in a slot, sharing one header across all the object’s properties).

array elementvalueinlineabout 1 word eachyou getdense, orderedinteger index onlythe densest optionobject propertyslotin-objectabout 1 word eachyou getstring or symbol keysshape-optimisedpoor for churny keysMap or Set entrykeyvalnextslackabout 3 words + slackyou getany key typeinsertion orderO(1) has, delete, sizeFootprint grows left to right. So does what you can do.choose by the semantics you need, not by counting bytes
Per-item footprint grows from a packed array element, to an in-object property, to a Map or Set entry with its key, value, next link, and spare table capacity. What you can do with each grows in the same direction.

Read that as a menu, not a ranking. If you have a million integer-keyed values, a flat array is obviously right and a Map would be wasteful. But if your keys are objects, or you delete entries constantly, or you rely on iteration order, a Map is the correct tool and its per-entry overhead is the honest price of those features. Reaching for a plain object to save a few bytes there, then reinventing ordered iteration and safe deletion by hand, trades a little memory for a lot of bugs. Pick the structure whose semantics match the problem, and let the footprint follow.

Try it: estimate the footprint

Guessing is a bad way to reason about this, but so is having no feel for the numbers at all. The estimator below is the middle ground: pick a structure and a count and it gives an order-of-magnitude footprint, split into the data you wanted and the overhead you did not. The coefficients are rough and illustrative, a 64-bit build with pointer compression, small values. Real numbers vary. The point is the shape of the answer: watch how the overhead slice swells the moment every item becomes its own object.

interactiveMemory-cost estimator (rough, illustrative)

Move it to ten million wrapper objects and the estimate crosses into hundreds of megabytes, most of it overhead. Switch to the packed array and it collapses. That is the intuition to keep. It is not a promise about exact bytes.

How to actually see it

When it matters, do not estimate, measure. Chrome DevTools and Node both let you take a heap snapshot, and the column to read is not shallow size (the object itself) but retained size: how much memory would be freed if this object went away, including everything only it keeps alive. Retained size is what turns “this cache looks small” into “this cache is pinning 90 MB”, because the map is tiny but each entry retains a large object graph. Running that down, finding what is stuck and why, is its own lesson on heap snapshots. The habit to build now: when memory surprises you, open a snapshot and sort by retained size instead of guessing from the source.

The honest calibration

Everything here is intuition for choosing data structures when you have a lot of items or a tight budget: millions of records in memory, a long-lived cache, a service on a small edge instance, a data-heavy view on a low-end phone. There, picking a flat array over a million wrappers, or being deliberate about a Map, is a real and easy win.

It is noise for a config object, a props bag, the twelve-item list behind a dropdown, or the handful of objects a request handler builds and throws away. Rewriting a readable { user, roles, prefs } into parallel arrays to save a few words is a bad trade: you make the code worse and measure nothing. Clarity wins by default. Reach for the dense layout when the count is large and a profiler or a snapshot has pointed at the cost, not on a hunch that objects are expensive.

Summary

  • Every object is a small fixed header plus its data. For a normal V8 object the header is three words: a pointer to its hidden class, a pointer to the properties store, and a pointer to the elements store. An empty object is not zero bytes; its stores are shared empty singletons until you add data.
  • The first several properties live in fast in-object slots read by a fixed offset; extras spill to a separate properties store (one more pointer hop), and objects used as churny key bags fall to slow dictionary mode. Slack tracking hands new objects a little inline headroom.
  • Most fields hold pointers. A small integer is inline and free, but a boxed number or a string is a separate heap object the field only points at, which is where extra allocations and pointer chases come from (see Smis and doubles and strings in the engine).
  • At scale, cost tracks the number of separate objects, not the size of the values. A million one-field wrappers cost several times a packed array of the same numbers, because you pay a header and a pointer per item (the memory side of elements kinds).
  • Map and Set carry more per-entry overhead than a plain object or array, and that is the fair price of any-key lookup, insertion order, and O(1) has/delete/size. Choose by semantics, then let footprint follow.
  • Exact byte counts are build-specific and change; other engines share the same anatomy under different names. Measure retained size in a heap snapshot when it matters.

What this means for your code: for large collections of simple items, prefer fewer, denser structures (a flat or typed array over a million wrapper objects) and pick Map/Set for the semantics, not against them. For everything smaller, write the clearest version and let a profiler tell you if layout ever becomes the problem.