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.
A function object, and what it costs to make one
"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.
- 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.
- JSFunction: one per closure creation (
CreateClosurebytecode). 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. - 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
CreateFunctionContextorCreateBlockContexton scope entry. - Code: shared through the SFI for Ignition and Sparkplug; optimised code is per SFI too (not per closure), keyed by the feedback vector.
- 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.
- 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
letallocates a context per iteration. - 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.
- What the optimiser removes: closures created and immediately called or passed to an inlined builtin (the
mapcallback, whenmapis inlined) can be inlined too; their context can be escape-analysed away. The per-element callback in a hotarr.map(x => x * 2)often costs nothing at all once optimised.
--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.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.
// 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.- 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]hopsdepthlinks. - 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. - 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. - Module scope and script scope are contexts too; top-level
letandconstin a module live in the module's context, and functions in the module capture it.
- One context per scope activation. Not per closure. Every closure created in that activation points at the same context.
- 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.
- 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.
- 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
nullwhen 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.
(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.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.
- 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. - Plain call
f(): no receiver. In strict code (modules, classes, "use strict")thisisundefined. In sloppy code the callee's prologue replaces undefined withglobalThis, a historical accident that makesthis.x = 1in a mis-called function create a global. new f(): an object is allocated from f's initial map and passed asthis;new.targetis f; if f returns an object that becomes the result.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;applywith a dynamically sized array goes through a runtime path that materialises the arguments.- Bound functions
f.bind(t, ...): a JSBoundFunction wrapper; calling it calls f withthis = tand the bound arguments prepended. A bound function used withnewignores the boundthis. One allocation at bind time; each call does an unwrap, which the optimiser removes when it can see the target. - Arrow functions: no
this,arguments,superornew.targetof their own. The parser resolvesthisinside an arrow as a reference to the enclosing non-arrow function'sthis, which is then context-allocated and captured like any variable. No call form can change it. At the top level of a module,thisisundefined; in a script,globalThis.
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)- Arrow wrapper
() => this.handle(): clear, one closure, inlines well. Default. bindonce in the constructor: one allocation per instance per method; reads a little heavier; the removal case (removeEventListenerneeds the same reference) works because you stored it.- 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. - Never pass an unbound method as a callback and expect
this.
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.
- 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.
- 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.
- 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(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.lengthor indexing only). Fine in optimised code; an allocation in the interpreter.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 useargumentsin sloppy code; use strict mode (modules are).- Rest parameters
(...rest): a real array (PACKED_ELEMENTS) built from the extra arguments. One allocation; escape-analysed in the common cases. - 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. - 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.
- Destructured parameters are property loads with their own ICs; free once warm.
- Megamorphic call targets: an indirect call; no inlining; the callee's own feedback is shared with all its callers.
applywith 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.- Getters and Proxies on the receiver chain (part 7).
- Try/catch around a call: free now.
finally: cheap. Throwing: expensive (stack trace capture, unwinding); exceptions are for exceptions. - 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).
Function kinds and what they cost
| Kind | What it is at runtime | Has own this/arguments? | Cost notes |
|---|---|---|---|
| Function declaration / expression | JSFunction with a prototype property (constructable) | Yes | Baseline; hoisted declarations are created at scope entry |
| Arrow function | JSFunction, not constructable, no prototype property | No (captures) | Slightly smaller; this access is a context load |
| Method (class or object literal shorthand) | JSFunction with a HomeObject (for super), not constructable | Yes | Same as a function; super.x is a cached prototype load |
| Class constructor | JSFunction callable only with new (throws otherwise) | Yes | Initial map cached; derived constructors have a this TDZ check |
| Generator function | Creates a JSGeneratorObject holding a suspended frame; the body is compiled with suspend points | Yes | One object plus a register save area per instance; each next resumes a frame (part 9) |
| Async function | Like a generator plus a promise; await is a suspend point with a promise reaction | Yes | A promise and a generator-like object per call; each await is a microtask hop (part 10) |
| Async generator | Both of the above | Yes | Two layers of machinery; a queue of pending requests |
| Bound function | JSBoundFunction wrapping a target | Fixed | One 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 call | Yes | Torque 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++ callback | Yes | A call into the embedder: argument marshalling, HandleScope; never inlined; tens of ns minimum |
new Function(src) / eval | A freshly parsed function each time | Yes | Full parse and compile per call; no code cache; disables optimisations in enclosing scopes for direct eval |
| Getter / setter | A function stored in an accessor descriptor | Yes | A call in the interpreter; inlined when monomorphic and small in optimised code |
for...of actually does per iteration. Every async function is a generator underneath, so part 9 is the foundation for part 10.Reading functions: tools
| Tool | Shows | Use it for |
|---|---|---|
--print-bytecode: look for CreateFunctionContext, CreateClosure, Lda/StaContextSlot, CreateMappedArguments, CallProperty vs CallUndefinedReceiver | Captures, closure creation, context access depth, arguments materialisation, how this was passed | What 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 pane | Local, Closure (named by the creating function), Script/Module, Global; which variables are in each | Seeing exactly what a closure captured, live |
DevTools Memory → snapshot → (closure) and the context edges in Retainers | Closures by function name and what their contexts retain | The closure leak procedure |
--trace-turbo-inlining | Each inlining decision and its reason | Whether a callback was inlined into the builtin that calls it |
--trace-turbo-escape | Escape analysis per allocation, including contexts and arguments objects | Whether 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-limit | The frames, with inlined frames reconstructed | Where a function was called from; costs a trace capture, so not in hot paths |
node --stack-size=N | Changes the stack limit (KB) | Deep recursion diagnostics; not a fix for unbounded recursion |