Escape Analysis and Short-Lived Allocations in V8

A micro-benchmark says creating { x, y } objects in a loop is free, while the allocation profiler shows the same pattern dominating a real page — and you want to know which to believe. This guide from Stack vs Heap Memory Allocation in JavaScript, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains V8’s escape analysis: how the optimising compiler can turn a heap object into a handful of registers, and why that optimisation is fragile enough that you should not depend on it.

Symptom Root Cause Immediate Action Measurable Impact
Benchmark shows zero allocation, real app shows heavy churn Optimised, inlined code in the benchmark; unoptimised or non-inlined in the app Profile the real flow, not an isolated loop Decisions based on real allocation
Allocation disappears, then reappears after a refactor A change made the object escape (stored, returned, passed to non-inlined call) Keep hot temporaries local; avoid passing them to large functions Stable allocation behaviour
Hot function allocates heavily only for the first seconds Runs in the interpreter before optimisation Accept warm-up cost; measure steady state Separates warm-up from steady-state churn
Deoptimisation in a hot loop brings back allocation Type feedback changed; optimised code discarded Keep argument types and object shapes stable Optimised, allocation-free code stays in use
Point/vector helpers cost memory in some call sites only Inlining succeeded in some callers, not others Check with --trace-deopt and allocation sampling per caller Targeted fixes where objects really escape

Root Cause: Objects That Do Not Escape Need Not Exist

In the language model, every object literal and every new creates a heap object. In practice, V8’s optimising compiler, TurboFan, runs an escape analysis pass over the optimised graph. An allocation escapes if the object can be observed outside the code being compiled: it is stored into another object or array, assigned to a variable captured by a closure, returned, thrown, passed to a function that was not inlined, or exposed to generic operations the compiler cannot see through. If an object does not escape, the compiler can perform scalar replacement: it replaces the object with its individual fields held in registers or stack slots and deletes the allocation entirely. The object never touches the heap, so it costs neither allocation time nor garbage collection.

Consider a distance helper that returns { dx, dy } to a caller that immediately reads both fields. If the helper is small enough to be inlined into the hot caller, the returned object is visible only inside the optimised caller, does not escape, and can be scalar-replaced. That is why a micro-benchmark of such a loop often shows no garbage at all.

The catch is that every link in that chain is conditional. The function must be hot enough to be optimised; before that it runs in the Ignition interpreter (and the mid-tier Maglev compiler, which performs fewer of these transformations), where every allocation is real. The helper must be inlined, which depends on its size, the caller’s size and the inlining budget. The object must not escape anywhere in the optimised graph — a single debug log, a store into an array “for later”, or a call to a function that is too large to inline makes it escape. And the optimised code must stay in use: a deoptimisation caused by new type feedback sends execution back to the interpreter, where allocation returns until re-optimisation. Real applications, with larger functions and more varied types than benchmarks, lose the optimisation far more often. That is the gap you see between the benchmark and the allocation sampling profiler.

When escape analysis removes an allocation Four conditions in sequence must all hold for a temporary object to be removed: the function is optimised by TurboFan, the helper creating the object is inlined, the object does not escape through stores, returns to non-inlined code, closures or throws, and the optimised code is not deoptimised. If all hold, the object becomes registers. If any fails, it is a real heap allocation that the garbage collector must handle. Optimised by TurboFan? Helper inlined? Object does not escape? No deopt afterwards? All yes → scalar replacement fields live in registers; no heap object Any no → real allocation young-generation object, collected by scavenges escapes: stored, returned to non-inlined code, captured, thrown, logged

Step-by-Step Fix

  1. Measure in the real application. Record an Allocation sampling profile (with objects discarded by GC included) of the real flow in DevTools → Memory. Verification: you know which functions allocate most in steady state, not in a synthetic loop.
  2. Check warm-up versus steady state. Record twice — once right after load and once after the flow has run many times. Verification: if allocation drops sharply in the second recording, optimisation (and possibly escape analysis) is removing it once warm; the first recording shows interpreter cost.
  3. Look for escape points in the hot function. Inspect the function for stores of temporaries into arrays or objects, logging, closures that capture them, and calls to large helpers. Verification: you have a list of reasons the temporary might escape.
  4. Check for deoptimisations in Node. Run the workload with node --trace-deopt --trace-opt script.js and filter for the hot function. Verification: you see whether the function is optimised and whether it is repeatedly deoptimised.
  5. Remove the allocation structurally when it matters. Rather than hoping for scalar replacement, write hot paths to use primitives or preallocated objects — return two numbers via an out-parameter object you reuse, or inline the arithmetic. Verification: allocation profiles show the function no longer allocates in both warm and cold runs.
  6. Stabilise types. Keep argument types and object shapes consistent in hot functions (always numbers, always the same property order) to avoid deoptimisation. Verification: --trace-deopt stays quiet for the function during the workload.
Same helper, three outcomes For one million calls of a helper returning an object with dx and dy, the interpreter allocates about 32 megabytes, optimised code with the helper inlined allocates essentially 0 because the object is scalar-replaced, and optimised code where the object is also pushed into a debug array allocates about 32 megabytes plus the array growth because it escapes. Bytes allocated per 1M calls of delta(a, b) → { dx, dy } Interpreter (cold) ~32 MB TurboFan, inlined, no escape ~0 — scalar-replaced TurboFan, pushed to debug log ~32 MB + log growth (escapes)

Command and Code Reference

Use case: see optimisation and deoptimisation of a hot function in Node.js.

# Print when functions are optimised and why they deoptimise; filter for the hot one
node --trace-opt --trace-deopt bench.js 2>&1 | grep -E "delta|hotLoop" | head -40

Use case: a helper whose temporary can be scalar-replaced, and the one-line change that makes it escape.

// Small helper: likely to be inlined into hot callers
function delta(a, b) {
  return { dx: b.x - a.x, dy: b.y - a.y };  // temporary result object
}

function pathLength(points) {
  let total = 0;
  for (let i = 1; i < points.length; i++) {
    const d = delta(points[i - 1], points[i]);   // does not escape if delta is inlined
    total += Math.sqrt(d.dx * d.dx + d.dy * d.dy);
  }
  return total;
}

const debug = [];
function pathLengthLogged(points) {
  let total = 0;
  for (let i = 1; i < points.length; i++) {
    const d = delta(points[i - 1], points[i]);
    debug.push(d);                                // escapes: now a real heap object
    total += Math.sqrt(d.dx * d.dx + d.dy * d.dy);
  }
  return total;
}

Use case: write the hot path so it does not rely on escape analysis at all.

// Allocation-free by construction: no temporary object in any tier
function pathLengthPrimitive(xs, ys, n) {
  let total = 0;
  for (let i = 1; i < n; i++) {
    const dx = xs[i] - xs[i - 1];                 // plain numbers in registers
    const dy = ys[i] - ys[i - 1];
    total += Math.sqrt(dx * dx + dy * dy);
  }
  return total;
}

Verification and Regression Prevention

Verify decisions with allocation profiles of the real application in both cold and warm states, not with micro-benchmarks alone. A hot path that is allocation-free by construction shows no allocation in either state; a path relying on escape analysis shows allocation when cold and possibly none when warm. If the steady-state profile is clean and the cold-start cost is acceptable, leave the readable version; if not, rewrite the hot path with primitives or preallocated storage.

Guard hot paths with a lab test that records sampled allocation for a representative workload after warm-up and fails when bytes exceed a budget. That catches the refactor that quietly makes an object escape — a new log statement or a helper that grew too large to inline. For the broader patterns of reducing temporary allocation, see reducing garbage churn found in allocation timelines.

Reading cold and warm allocation profiles Profile the real application cold and warm. A path with no allocation in either state is allocation-free by construction. A path that allocates when cold but not when warm is relying on escape analysis in optimised code. A path allocating in the steady state is a candidate for restructuring if the profile shows it matters. Allocation in cold vs warm profile Allocation-free by construction none in either Relies on escape analysis; fine if warm dominates cold only Restructure if the profile shows it matters steady-state allocation

Edge Cases and Gotchas

Debug-only code changes behaviour

Assertions and logging that reference temporaries make them escape. If your development build logs more than production, allocation profiles of the development build overstate production churn. Profile production builds.

Arrays are harder to eliminate than small objects

Small, fixed-shape objects are the best candidates for scalar replacement. Arrays with dynamic length or indexed access by variable indices are rarely eliminated. Do not expect [a, b] tuples in hot loops to be free.

Closures capture, and capturing escapes

A temporary referenced by an arrow function created in the same loop is stored in a context, which is itself a heap object. That makes the temporary escape unless the closure is also inlined and eliminated — which is uncommon.

Tier differences

The interpreter never removes allocations; the mid-tier compiler removes few; TurboFan removes the most. Short-lived pages or rarely used code paths may never reach TurboFan, so their allocations are always real.

Frequently Asked Questions

Does JavaScript allocate objects on the stack?

Not in the language model — every object is a heap object. V8’s optimising compiler can, however, eliminate an allocation entirely when the object provably never escapes, keeping its fields in registers. That is an optimisation, not a guarantee.

Should I write code to help escape analysis?

Write hot paths so they do not need it: use primitives, reuse preallocated objects, or restructure to avoid temporaries. Elsewhere, write clear code and let the compiler do what it can; the difference matters only where profiles show allocation dominating.

Why do benchmarks disagree with profiles of my app?

Benchmarks usually run one small function in a tight loop until it is optimised and inlined, the ideal case for escape analysis. Real apps call larger functions from many sites with varied types, so inlining and escape analysis succeed less often, and cold code never gets optimised at all.

Can I see whether an allocation was removed?

Indirectly: compare allocation sampling profiles of cold and warm runs, or count Minor GC events for a steady workload. Node’s --trace-turbo output shows the compiler’s graphs in detail, but it is intended for engine developers and is rarely worth the effort for application work.