Part 8 · 6 chapters · ~45 min

Functions And Closures

A closure is two pointers and a shared context; this is a per-call value with five sources; a call is a frame that the optimiser would rather not create. This part is the function object and what creating one costs, scope and the context sharing rule that explains closure leaks, the five bindings of this and the arrow non-binding, frames, arity, arguments, rest and spread, every function kind compared, and the tools.

58

A function object, and what it costs to make one

the question

"Is creating a closure expensive? There is one per element in this map callback."

Creating a closure is one small allocation. What it captures is where the cost and the leaks are. A function object in V8 is a JSFunction: a map, a pointer to the SharedFunctionInfo (the bytecode, feedback metadata and source positions that all instances of that function share), a pointer to the Context it closed over, and a code entry pointer. Around 32 to 64 bytes. The context, allocated once per scope activation that has captured variables, is where the captured values live.

the objects
  1. SharedFunctionInfo: one per function in the source. Holds the bytecode (or the lazy-compile stub), the scope info, the feedback metadata, the source range, flags. Never duplicated by creating closures.
  2. JSFunction: one per closure creation (CreateClosure bytecode). Points to the SFI and the current context. Also holds the feedback cell: closures from the same site usually share one feedback vector (so a callback created per element still has warm feedback), with a mechanism to split it if they behave differently.
  3. Context: one per activation of a scope that has at least one context-allocated variable. Slots for each such variable; a pointer to the enclosing context. Allocated by CreateFunctionContext or CreateBlockContext on scope entry.
  4. Code: shared through the SFI for Ignition and Sparkplug; optimised code is per SFI too (not per closure), keyed by the feedback vector.
the costs
  1. Per closure creation: one JSFunction allocation. In the young generation; dies young if the closure does. A million per second is 32 to 64 MB/s of allocation: noticeable, not catastrophic.
  2. Per scope entry with captures: one Context allocation, plus a store per captured variable. A loop body with a closure that captures a per-iteration let allocates a context per iteration.
  3. Per captured-variable access: a load through the context pointer, plus one hop per enclosing scope level for outer variables. Register access is free; context access is a memory load. The optimiser hoists invariant context loads out of loops.
  4. What the optimiser removes: closures created and immediately called or passed to an inlined builtin (the map callback, when map is inlined) can be inlined too; their context can be escape-analysed away. The per-element callback in a hot arr.map(x => x * 2) often costs nothing at all once optimised.
run it
--print-bytecode on a function that returns an arrow: CreateFunctionContext and CreateClosure appear. Remove the capture (make the arrow not reference any outer variable) and CreateFunctionContext disappears. That line is the difference between a 32-byte allocation and a 32-byte allocation plus a context.
WHAT A CLOSURE IS
a function object, a context, and what the context shares
swipe the figure sideways, or tap expand for full screen
1/6
one context
function makeCounter() { let n = 0; const big = loadData(); return { inc: () => ++n, peek: () => n } }. Calling makeCounter creates one Context for the activation: slots for n and big (both captured: n by the arrows, big by ... nothing, in this version; see step 4).
59

Scope, contexts, and the sharing rule

Part 1 showed the parser deciding register or context for each variable. Here is the runtime consequence and the one rule that explains closure leaks: a variable captured by any inner function lives in a context shared by all inner functions of that scope.

code
// what the parser decides for each variable (part 1), as it shows up at runtime
function handler(evt) {                 // evt: register (nothing captures it)
  const el = evt.target                 // el: context slot (the arrow below captures it)
  const rows = buildRows(evt.data)      // rows: context slot (captured by... see below)
  const id = el.dataset.id              // id: register
  table.replaceChildren(...rows)
  el.addEventListener('click', () => {  // this closure's context = handler's context: {el, rows}
    highlight(el)                       // uses el only. but the context also holds rows.
  })
}
// rows is captured because some inner function references it? no: nothing references rows inside a closure here...
// correction: in this version rows is NOT captured (no inner function reads it) → register → dies with the frame. good.
// add one line:  console.log(() => rows.length)  → now rows is context-allocated, and the click handler retains it forever.

// the rule: a variable is in the context if ANY inner function references it; the context is shared by ALL inner functions.
how contexts chain
  1. Each context points to its enclosing context. A closure's context pointer is the context of the scope it was created in; from there, outer scopes are reached by following the chain. LdaContextSlot [depth], [slot] hops depth links.
  2. Block scopes get contexts only when needed: a { let x } block with no closure inside has x in a register. With a closure capturing x, a block context is pushed on entry.
  3. Loop per-iteration bindings: for (let i ...) with a closure in the body creates a new context per iteration and copies i forward, so each closure sees its own i. That is the semantics you want and an allocation per iteration you pay.
  4. Module scope and script scope are contexts too; top-level let and const in a module live in the module's context, and functions in the module capture it.
the sharing rule and its consequence
  1. One context per scope activation. Not per closure. Every closure created in that activation points at the same context.
  2. So: a long-lived closure keeps alive every context-allocated variable of its scope, whether or not it uses them, because a sibling closure (even one that no longer exists) caused them to be context-allocated.
  3. V8's mitigation: only variables that some inner function references are context-allocated; unreferenced ones stay in registers and die with the frame. Older engines captured everything; V8 does not. But referenced-by-anyone is still broader than referenced-by-this-closure.
  4. Fixes: (a) build the long-lived closure in a separate function whose scope does not contain the large variable; (b) assign the large variable to null when done with it, so the slot holds nothing; (c) pass values as arguments instead of closing over them; (d) avoid debug or logging closures that reference large locals in the same scope as production callbacks.
the lens
In a heap snapshot, a closure leak's retainer path goes through an entry labelled (context) or shown as the enclosing function's name with a context edge. The variable name on the edge is the one to null or move.
60

this: five bindings and one non-binding

this is a per-call value chosen by the call site, not by where the function was written. There are exactly five ways a call can set it and one kind of function that has no this of its own. Every this bug is a mismatch between what the author assumed and which of the five forms the caller used.

the forms
  1. Method call obj.f(), obj['f'](): the receiver is the object the property was read from, even if the property lives on a prototype. The property read and the call are fused by the bytecode (CallProperty) so the receiver is passed.
  2. Plain call f(): no receiver. In strict code (modules, classes, "use strict") this is undefined. In sloppy code the callee's prologue replaces undefined with globalThis, a historical accident that makes this.x = 1 in a mis-called function create a global.
  3. new f(): an object is allocated from f's initial map and passed as this; new.target is f; if f returns an object that becomes the result.
  4. f.call(t, ...), f.apply(t, args), Reflect.apply(f, t, args): explicit receiver. The optimiser turns known-arity call and apply into direct calls; apply with a dynamically sized array goes through a runtime path that materialises the arguments.
  5. Bound functions f.bind(t, ...): a JSBoundFunction wrapper; calling it calls f with this = t and the bound arguments prepended. A bound function used with new ignores the bound this. One allocation at bind time; each call does an unwrap, which the optimiser removes when it can see the target.
  6. Arrow functions: no this, arguments, super or new.target of their own. The parser resolves this inside an arrow as a reference to the enclosing non-arrow function's this, which is then context-allocated and captured like any variable. No call form can change it. At the top level of a module, this is undefined; in a script, globalThis.
code
class Button {
  constructor() { this.clicks = 0 }
  handle() { this.clicks++ }
}
const b = new Button()
el.addEventListener('click', b.handle)            // this = el (the event target): wrong object; clicks is NaN on el
el.addEventListener('click', () => b.handle())    // arrow: b.handle() is a method call; this = b
el.addEventListener('click', b.handle.bind(b))    // bound: this = b; allocates one JSBoundFunction once
class Button2 { handle = () => { this.clicks++ } } // class field arrow: a closure per instance (memory) but this is fixed

// cost ranking for a hot handler, warm: arrow wrapper ≈ bound ≈ field-arrow (all one indirection or inlined)
// memory ranking: prototype method (one fn) < bound (one wrapper per bind) < field arrow (one closure per instance)
which to use for callbacks
  1. Arrow wrapper () => this.handle(): clear, one closure, inlines well. Default.
  2. bind once in the constructor: one allocation per instance per method; reads a little heavier; the removal case (removeEventListener needs the same reference) works because you stored it.
  3. Class field arrow handle = () => {}: a closure per instance, not shared on the prototype; memory per instance; cannot be overridden by subclasses the usual way; convenient for React-style handlers. Fine for few instances; wrong for ten thousand.
  4. Never pass an unbound method as a callback and expect this.
HOW THIS IS BOUND
the five call forms and the one that does not bind
swipe the figure sideways, or tap expand for full screen
1/6
method call
obj.method(): the receiver is the object the property was read from. In bytecode: GetNamedProperty obj "method" leaves the function in the accumulator; CallProperty passes obj as the receiver register. this inside method is obj.
61

Calls: frames, arity, arguments, rest, spread

A call is cheap in V8 (a few nanoseconds in optimised code, often zero when inlined), and the things around it that used to be expensive (arity mismatch, arguments, spread) have mostly been fixed or given fast paths. What remains costly is predictable.

the frame
  1. Layout: receiver and arguments pushed by the caller; then the callee's return address, frame pointer, context, function, argument count, bytecode array and offset, then its registers. Optimised frames have the same logical content in machine registers and stack slots, with a translation table for deopts.
  2. Arity mismatch: fewer arguments than parameters: the missing ones read as undefined from a padded arguments area; more: the extras sit on the stack unused. Neither needs an adapter frame since V8 8.7 (2020). Calling with inconsistent arity is no longer a performance concern, except that the call IC may record it.
  3. Inlining: removes the frame entirely; the callee's registers are allocated in the caller. The deoptimiser can still reconstruct both frames from the translation table.
arguments, rest, spread
  1. arguments (strict): an unmapped arguments object, allocated on first use, a copy of the actual arguments. Escape-analysed away if it does not leave the function (e.g. arguments.length or indexing only). Fine in optimised code; an allocation in the interpreter.
  2. arguments (sloppy, simple parameters): a mapped arguments object whose elements alias the parameters. Forces the parameters into a context-like backing store so writes through either are visible through both. Slows every parameter access in the function. Do not use arguments in sloppy code; use strict mode (modules are).
  3. Rest parameters (...rest): a real array (PACKED_ELEMENTS) built from the extra arguments. One allocation; escape-analysed in the common cases.
  4. Spread calls f(...arr): CallWithSpread; a fast path for real arrays with an unmodified iterator and PACKED elements copies them to the stack directly; otherwise generic iteration. f.apply(null, arr) is equivalent and shares the fast path.
  5. Default parameters are evaluated in a scope between the caller's and the body's; a default that is a closure creates a context. Cheap unless the default allocates.
  6. Destructured parameters are property loads with their own ICs; free once warm.
what is still expensive around a call
  1. Megamorphic call targets: an indirect call; no inlining; the callee's own feedback is shared with all its callers.
  2. apply with huge argument arrays: arguments are pushed to the stack; a million-element spread overflows it ("Maximum call stack size exceeded"). Use loops for big data.
  3. Getters and Proxies on the receiver chain (part 7).
  4. Try/catch around a call: free now. finally: cheap. Throwing: expensive (stack trace capture, unwinding); exceptions are for exceptions.
  5. Recursion depth: each frame costs stack; the default limit is around 10,000 frames in Node (~1 MB stack). Tail calls are not optimised in V8 (the spec has them; V8 never shipped them).
A CALL, FRAME BY FRAME
arguments, the frame, and what arity mismatches cost
swipe the figure sideways, or tap expand for full screen
1/6
the call
Caller: f(1, 2). Bytecode: Ldar/Star the arguments into consecutive registers, then CallUndefinedReceiver2 r_f, r_args, [slot]. The call builtin checks that r_f is callable, loads its code, and jumps.
62

Function kinds and what they cost

KindWhat it is at runtimeHas own this/arguments?Cost notes
Function declaration / expressionJSFunction with a prototype property (constructable)YesBaseline; hoisted declarations are created at scope entry
Arrow functionJSFunction, not constructable, no prototype propertyNo (captures)Slightly smaller; this access is a context load
Method (class or object literal shorthand)JSFunction with a HomeObject (for super), not constructableYesSame as a function; super.x is a cached prototype load
Class constructorJSFunction callable only with new (throws otherwise)YesInitial map cached; derived constructors have a this TDZ check
Generator functionCreates a JSGeneratorObject holding a suspended frame; the body is compiled with suspend pointsYesOne object plus a register save area per instance; each next resumes a frame (part 9)
Async functionLike a generator plus a promise; await is a suspend point with a promise reactionYesA promise and a generator-like object per call; each await is a microtask hop (part 10)
Async generatorBoth of the aboveYesTwo layers of machinery; a queue of pending requests
Bound functionJSBoundFunction wrapping a targetFixedOne allocation per bind; an unwrap per call unless inlined
Builtin (Array.prototype.map, Math.max…)Code written in Torque/CSA, or C++ behind an API callYesTorque builtins have fast paths and can be inlined by TurboFan; C++ builtins cost a runtime transition per call
Native/host function (DOM method, fs.readFileSync)An API function with a C++ callbackYesA call into the embedder: argument marshalling, HandleScope; never inlined; tens of ns minimum
new Function(src) / evalA freshly parsed function each timeYesFull parse and compile per call; no code cache; disables optimisations in enclosing scopes for direct eval
Getter / setterA function stored in an accessor descriptorYesA call in the interpreter; inlined when monomorphic and small in optimised code
the pointer
Part 9 opens the generator row: how a function that suspends keeps its frame, and what for...of actually does per iteration. Every async function is a generator underneath, so part 9 is the foundation for part 10.
63

Reading functions: tools

ToolShowsUse it for
--print-bytecode: look for CreateFunctionContext, CreateClosure, Lda/StaContextSlot, CreateMappedArguments, CallProperty vs CallUndefinedReceiverCaptures, closure creation, context access depth, arguments materialisation, how this was passedWhat a function's scope decisions cost at runtime
%DebugPrint(fn)The JSFunction: its SharedFunctionInfo, context, feedback vector, code kind (interpreted / baseline / Maglev / TurboFan)Which tier a specific closure runs in; what context it holds
DevTools Sources → pause → Scope paneLocal, Closure (named by the creating function), Script/Module, Global; which variables are in eachSeeing exactly what a closure captured, live
DevTools Memory → snapshot → (closure) and the context edges in RetainersClosures by function name and what their contexts retainThe closure leak procedure
--trace-turbo-inliningEach inlining decision and its reasonWhether a callback was inlined into the builtin that calls it
--trace-turbo-escapeEscape analysis per allocation, including contexts and arguments objectsWhether the closure's context was eliminated
fn.length, fn.name, fn.toString()Declared parameter count, name inference, source text (from the SFI's source range)Quick checks; toString on a bound function returns "function () { [native code] }"
new Error().stack / Error.captureStackTrace / --stack-trace-limitThe frames, with inlined frames reconstructedWhere a function was called from; costs a trace capture, so not in hot paths
node --stack-size=NChanges the stack limit (KB)Deep recursion diagnostics; not a fix for unbounded recursion
run it
Pause in DevTools inside any callback and open the Scope pane. The "Closure" section lists exactly the context-allocated variables of the creating scope, which is exactly what the callback keeps alive. If something large is there that the callback never uses, that is the sharing rule in action.