Primitives vs Objects: Where JavaScript Values Live

The single most useful mental model for JavaScript memory is knowing, for any variable, whether it holds a value or a pointer — because that decides what a copy costs, what a closure retains, and what a snapshot shows. This page belongs to stack vs heap memory allocation in JavaScript inside JavaScript Memory Fundamentals & Runtime Mechanics.

Symptom Root Cause Immediate Action
A closure over “one number” retains megabytes The number was read from a captured object Destructure the primitive at capture time
An array of numbers costs 8× the expected size Doubles boxed as individual heap numbers Use a typed array
Mutating a “copy” changed the original Assignment copied the pointer, not the object Clone explicitly where independence is needed
A snapshot shows a huge retained size on a small value The value is a pointer into a large subgraph Read the retainer chain, not the shallow size
Passing a large object to a function seems free Only the pointer is passed It is free — the cost is retention, not passing

Root Cause: Tagged Values and Pointers

V8 stores every variable slot as a machine word with a tag. If the tag says “small integer”, the value is encoded directly in the word and no allocation exists anywhere — this is why counters and array indices are effectively free. If the tag says “pointer”, the word is an address into the heap, and the real data lives there.

That split does not follow the primitive-versus-object line exactly. Booleans, null, undefined and small integers are inline. Strings are primitives but heap-allocated, because character data does not fit in a word. Arbitrary doubles are primitives but usually heap-allocated as HeapNumber objects, unless they sit in a context V8 can specialise, such as a double-elements array or a typed array.

The consequences are practical. Copying: assigning a primitive duplicates the value; assigning an object duplicates a pointer, so both names see the same mutation. Capturing: a closure over a small integer retains eight bytes; a closure over an object retains that object’s entire reachable subgraph — the mechanism behind most closure memory leaks. Measuring: a snapshot’s shallow size for a variable slot is a word, while its retained size can be megabytes.

Four slots: two values, two pointers A count of forty-two and a boolean are stored inline in their slots. A string variable holds a pointer to heap-allocated character data. An order variable holds a pointer to an object whose reachable subgraph is ninety-six kilobytes. All four slots are the same size on the stack. Every slot is one machine word; only two of them contain the data Variable slots count = 42 inline isValid = true inline label = ● pointer order = ● pointer string data · "Q3 summary — EMEA" · 40 B Order { id, lines: Array(240), customer } retained subgraph · 96 KB Heap Capturing the third slot retains 40 bytes. Capturing the fourth retains 96 kilobytes.

Step-by-Step Fix

  1. Classify the value. Expected: small integer or boolean → inline; everything else → a pointer.
  2. Ask what a copy would copy. Verification: mutate through one reference and read through the other; if the change is visible, it was a pointer.
  3. Ask what a capture retains. DevTools path: Memory → Heap snapshot → find the closure → read Retained Size. Expected: bytes for a primitive, the whole subgraph for an object.
  4. Check numeric arrays for boxing. Verification: compare heapUsed for the same values in Array and Float64Array; a factor of four or more means boxing.
  5. Destructure at the capture boundary. Verification: the closure’s retained size falls to the size of the values it actually uses.

Command & Code Reference

The capture difference, which is the practical payoff of the whole distinction.

// Retains the entire order — lines array, customer object, everything.
function makeHandlerBad(order) {
  return () => console.log(order.id);      // captures `order`
}

// Retains one small integer. The object becomes collectable as soon as the
// caller drops it, because the closure never referenced it.
function makeHandlerGood(order) {
  const { id } = order;                     // primitive copied out
  return () => console.log(id);
}

Proving the difference rather than asserting it.

// node --expose-gc capture.js
function retainedBy(makeHandler) {
  global.gc();
  const before = process.memoryUsage().heapUsed;
  const handlers = [];
  for (let i = 0; i < 10_000; i++) {
    // A fresh, large order per handler; only the handler is kept.
    handlers.push(makeHandler({
      id: i, lines: new Array(240).fill({ sku: 'x', qty: 1 }), customer: { name: 'n' },
    }));
  }
  global.gc();
  const after = process.memoryUsage().heapUsed;
  return ((after - before) / 1048576).toFixed(1) + ' MB';
}
console.log('captures the object:', retainedBy(makeHandlerBad));   // ~186 MB
console.log('captures the id:', retainedBy(makeHandlerGood));      // ~1.2 MB

Avoiding boxing for numeric data, which is the other place the value-versus-pointer split shows up in bytes.

// Each element is a pointer to a HeapNumber once the values are arbitrary
// doubles that do not fit the small-integer representation.
const boxed = new Array(1_000_000);
for (let i = 0; i < boxed.length; i++) boxed[i] = i * 1.0001;   // ~40 MB

// A contiguous backing store of raw 8-byte doubles: no pointers, no headers.
const packed = new Float64Array(1_000_000);
for (let i = 0; i < packed.length; i++) packed[i] = i * 1.0001; // exactly 8 MB
10 000 closures, two capture styles Ten thousand closures that capture the whole order object retain one hundred and eighty-six megabytes. Ten thousand closures that capture only the identifier retain one point two megabytes. The functions are otherwise identical. The only difference between these two is one destructuring line captures the object 186 MB — each closure holds a 240-line order it never reads captures the id only 1.2 MB V8 allocates one Context per scope, so the closure keeps whatever the scope's slots point at — which is the entire object when the object itself is the captured binding.

Verification & Regression Prevention

Verify by retained size, never by intuition. In a snapshot, select the closure or the variable and read Retained Size: a captured primitive shows a handful of bytes, a captured object shows its whole subgraph. That single number settles most arguments about whether a capture matters.

For prevention, adopt the habit of destructuring at boundaries — when a callback, a timer or a stored handler only needs an identifier, take the identifier. It costs one line, reads better, and removes a whole class of retention bug before it exists. Where the object genuinely is needed, that is fine; the goal is that the retention is deliberate rather than incidental.

Where each kind of value lives, and what capturing it costs Small integers and booleans are inline and retain nothing. Arbitrary doubles are heap numbers retaining eight bytes plus a header. Strings are heap-allocated and may retain a parent if sliced. Objects, arrays and functions are pointers retaining their whole subgraph. Six kinds of value, three different answers Value kind Stored as Capturing it retains small integer, boolean, null tagged, inline nothing extra arbitrary double HeapNumber, pointer 8 B plus a header string heap, pointer its data — or a parent, if sliced object, array heap, pointer the entire reachable subgraph function / closure heap, pointer to a Context every binding in the captured scope The last row is the one that turns a one-line callback into a retained response body.

FAQ

Are primitives always on the stack?

No. Small integers and booleans are stored inline as tagged values wherever the variable lives, which may be a stack frame, an object slot or a closure context. Strings and arbitrary doubles are heap-allocated even though they are primitives, because they do not fit in a tagged slot.

Why does copying an object not use more memory?

Because assignment copies the pointer, not the object. Two variables referring to the same object cost one object plus two pointer-sized slots. Only an explicit clone — spread, structuredClone, a manual copy — allocates a second object.

Does capturing one field of an object avoid retaining the rest?

Only if you capture the field’s value rather than the object. Destructuring a primitive out of the object at capture time retains a few bytes; capturing the object and reading the field inside the closure retains the whole object and everything it references.