Functions & closures inside the engine
Learn how V8 represents functions with SharedFunctionInfo, JSFunction objects, Contexts, feedback cells, bound functions, and arrows.
- 01Separate a function object from shared codeExplain why each function-literal evaluation creates a fresh JSFunction while V8 can share one SharedFunctionInfo for code and metadata.
- 02Read what a closure retainsConnect closed-over variables to heap Context objects, sibling closures, and the large values those contexts can keep reachable.
- 03Recognize special function wrappersDescribe feedback cells, JSBoundFunction wrappers, and arrow-function differences without confusing implementation details with JavaScript semantics.
The engine view of a function
You already know the language rule: a closure lets a function use variables from where it was created. This lesson looks one layer lower, inside V8 12.4 as shipped with Node 22. We will keep two ideas separate the whole way: JavaScript semantics are the promises your program gets; V8 objects are one engine's strategy for keeping those promises fast.
A function object in V8 is a JSFunction: the callable runtime object your code can compare and pass around. It points at shared code metadata called SharedFunctionInfo, and, when it is a closure, at a heap Context that stores closed-over variables.
If you want the language-level story first, use Closures, Function objects, call, apply, and bind, and Arrow functions. Scope analysis explains when V8 can keep variables on the stack versus when it must allocate a context. We build on those lessons instead of repeating them.
A family can use one recipe book while each person cooks their own meal. The instructions are shared, but each cook remembers their own amount. V8 similarly separates shared code, an individual callable object, and captured state.
- In real life: One recipe everyone reads
- In JavaScript: SharedFunctionInfo: reusable instructions and metadata
- In real life: Each cook follows it for one meal
- In JavaScript: A JSFunction created by evaluating the literal
- In real life: Each cook remembers their own amount
- In JavaScript: A Context holding values for one outer call
- In real life: Notes show which recipe is used often
- In JavaScript: Feedback cells and vectors collect runtime evidence
Where the analogy stops: A recipe book does not model machine storage. V8 can replace code pointers and optimize while preserving JavaScript behavior.
| Piece | How many? | What it holds |
|---|---|---|
SharedFunctionInfo | Shared by evaluations of the same function literal | Name, formal parameter count, bytecode/code metadata, source positions, ScopeInfo, and feedback metadata. |
JSFunction | One runtime function object | The object JavaScript code can call, compare, pass around, bind, or store in a variable. |
Context | One heap environment when a scope needs closed-over storage | The runtime slots that hold closed-over values such as seed, count, or a retained array. |
FeedbackCell / FeedbackVector | Runtime feedback for tiering and inline caches | Indirection and slots that record calls, property shapes, binary-operation hints, and hotness. |
Previous in this module: Fast arrays & elements kinds. Next in this module: Prototypes inside the engine.
JSFunction: one object per evaluation
A function literal is the source expression that creates a function, such as function inner() or () => 1. Every time execution evaluates that literal, V8 allocates a fresh JSFunction. That object has identity: two closures can run the same code and still be different objects.
Step through a teaching model of what evaluating one function literal does: reuse the shared recipe, allocate a new function object, and attach the current context.
script
let nextFunctionId = 1;let nextContextId = 1; function evaluateFunctionLiteral(seed) { const context = { id: "Context#" + nextContextId, seed }; nextContextId += 1; const jsFunction = { id: "JSFunction#" + nextFunctionId, shared: sharedFunctionInfo, context, }; nextFunctionId += 1; return jsFunction;} const first = evaluateFunctionLiteral(10);const second = evaluateFunctionLiteral(20);const third = evaluateFunctionLiteral(30);console.log(first.shared === second.shared);console.log(first.id !== second.id);console.log(first.context.id !== second.context.id);In the replay, lines 17 through 19 call the same factory three times. Each call creates a new context and a new function object. The important relation is line 20: the three objects point at one shared recipe, but line 21 says the function objects themselves are distinct.
The replay is a labelled model so you can see the relationship. The next section runs V8's %DebugPrint in Node and proves the same relation by comparing addresses inside one process. Tests assert equality and inequality relations, never exact addresses.
Contexts hold closed-over variables
A Context is V8's runtime heap object for variables that must outlive the stack frame that created them. V8's scopes documentation says the compiler decides which variables must be context-allocated; the runtime then creates a FunctionContext when the scope is entered.
Watch two closures created in one outer call share one context object. This is a teaching replay backed by the V8 DebugPrint proof below.
script
function outer() { const big = ["notes", "chart", "photo"]; const readBig = () => big.length; const answer = () => 1; return { readBig, answer };} console.log(closures.answer());console.log(closures.readBig());Line 2 stores big. Line 3 creates a closure that reads it. Line 4 creates a sibling closure that does not read it. V8's real debug output for this shape shows both sibling closures pointing at the same FunctionContext address.
function outer() { const big = ["notes", "chart", "photo"]; const readBig = () => big.length; const answer = () => 1; return [readBig, answer];} const [readBig, answer] = outer();%DebugPrint(readBig);%DebugPrint(answer);Tests parse both DebugPrint blocks. Their shared_info addresses differ because they are different arrow literals, but their context: addresses are equal because they were created in the same outer call.
What a closure keeps alive
A closure keeps alive what it can reach. In V8, that often means the JSFunction keeps a context alive, and the context keeps every object in its slots alive. This is where closure internals become practical memory knowledge.
Two classmates share one notebook. Even if one only needs a small note, keeping the notebook can keep a large drawing on another page too, because the whole notebook remains available.
- In real life: One notebook shared by two classmates
- In JavaScript: One FunctionContext shared by sibling closures
- In real life: One page holds a large drawing
- In JavaScript: A large array stored in a context slot
- In real life: One classmate keeps the notebook
- In JavaScript: The returned closure does not read the array
- In real life: All its pages stay with the notebook
- In JavaScript: Every reachable context slot remains reachable
Where the analogy stops: Real notebooks do not share memory. Engines can optimize contexts, so diagnose real leaks with heap snapshots and profiles.
function outer() { const big = new Array(1_000_000).fill("invoice"); const unused = () => big.length; return () => 1;} const keep = outer();console.log(keep());FunctionContext shared by siblingsbigyesA sibling closure reads `big`; V8 creates one shared FunctionContext, so the returned closure can keep that context alive too.
Only a sibling closure reads big: A sibling closure reads big; V8 creates one shared FunctionContext, so the returned closure can keep that context alive too. The teaching model says big is retained.
| Code shape | V8 context shape | Memory consequence |
|---|---|---|
No inner function uses big | The returned arrow can point at the script context | The local big can become collectible after the call. |
The returned function uses big | A FunctionContext must store big | The closure keeps big reachable while the function is reachable. |
Only a sibling function uses big | V8 can create one shared FunctionContext for siblings | The returned sibling can still keep that context, and therefore big, alive. |
function noSibling() { const big = new Array(10); return () => 1;} function withSibling() { const big = new Array(10); const unused = () => big; return () => 1;} const noContextClosure = noSibling();const sharedContextClosure = withSibling();%DebugPrint(noContextClosure);%DebugPrint(sharedContextClosure);bigJavaScriptfunction forceCollection() { for (let round = 0; round < 20; round += 1) { globalThis.gc(); const pressure = []; for (let i = 0; i < 50000; i += 1) pressure.push({ i }); } globalThis.gc();} function noSibling() { const big = { data: new Array(100000).fill(1) }; return { fn: () => 1, weak: new WeakRef(big) };} function withSibling() { const big = { data: new Array(100000).fill(1) }; const unused = () => big; return { fn: () => 1, weak: new WeakRef(big) };} (async () => { const a = noSibling(); const b = withSibling(); await new Promise((resolve) => setImmediate(resolve)); forceCollection(); await new Promise((resolve) => setImmediate(resolve)); forceCollection(); console.log("no sibling alive", a.weak.deref() !== undefined); console.log("with sibling alive", b.weak.deref() !== undefined); console.log(a.fn(), b.fn());})();Tests prove two stable facts. First, the no-sibling returned closure points at a ScriptContext, while the sibling version points at a FunctionContext. Second, after forced garbage collection, the no-sibling WeakRef clears and the sibling WeakRef remains alive while the returned closure remains reachable.
For production leak hunting, link this mental model to Memory management and Finding memory leaks. Long-lived listeners, timers, and caches are the usual roots, not the fact that a closure exists.
Feedback cells and many closures
Feedback is runtime evidence the engine collects for optimization: call targets, property shapes, binary-operation hints, and tiering budgets. V8 puts a FeedbackCell between a JSFunction and the full FeedbackVector, so it can allocate feedback lazily and share it in some safe cases.
function oneFactory(n) { return function addOne(x) { return x + n; };}const only = oneFactory(1);for (let i = 0; i < 40; i += 1) only(i);%DebugPrint(oneFactory); function manyFactory(n) { return function addMany(x) { return x + n; };}const first = manyFactory(1);const second = manyFactory(2);for (let i = 0; i < 40; i += 1) { first(i); second(i);}%DebugPrint(manyFactory);%DebugPrint(first);%DebugPrint(second);Tests assert that the first factory's closure feedback cell prints FeedbackCell[one closure], the second factory prints FeedbackCell[many closures], and the two warmed closures from the same literal report the same feedback vector: address while their context: addresses differ.
This is not a rule you need to code around. It explains why the engine can share code and sometimes share feedback while still giving each closure its own captured values. The previous type feedback and inline caches lessons go deeper on the feedback itself.
- The value returned by each call to
makeClosure(). - The inner function's name, bytecode, parameter count, and ScopeInfo.
- The
seedvalue from one factory call. - A large
bigarray used only by a sibling closure. - The warmed binary-op feedback for many closures of one literal.
bound_target_function,bound_this, andbound_arguments.- An arrow's
kind: ArrowFunctionand missing prototype slot.
Sort each internal fact by where it belongs in the model. The explanations connect the cards back to the V8 probes.
Bound and arrow functions internally
bind does not edit the original function. It creates a wrapper. V8 prints that wrapper as JSBoundFunction, with fields for the target function, the stored this value, and the stored arguments.
function total(fee, tax) { return this.base + fee + tax;} const bill = total.bind({ base: 100 }, 20);console.log(bill(5));console.log(bill.length);console.log(bill.name);function total(fee, tax) { return this.base + fee + tax;} const bound = total.bind({ base: 10 }, 1);%DebugPrint(bound);Arrows are different. They are still function objects, but their kind is ArrowFunction. They have no own this, no own arguments, no own prototype property, and they are not constructors.
this and arguments from outsideconst shop = { label: "Kochi counter", makeReader() { return () => this.label; },}; function makeFirst() { return () => arguments[0];} const reader = shop.makeReader();const first = makeFirst("Asha", "Ravi");console.log(reader.call({ label: "Mumbai counter" }));console.log(first());console.log("prototype" in first);try { new first();} catch (error) { console.log(error.name);}const arrow = () => this;%DebugPrint(arrow);| Kind | Internal shape | Practical behavior |
|---|---|---|
| Normal function | Usually has a prototype slot and may be a constructor | Own this is chosen by the call form or bind. |
| Bound function | A JSBoundFunction wrapper points at a target, bound this, and bound arguments | Calling it prepends stored arguments; construction behavior depends on the target. |
| Arrow function | A function object whose kind is ArrowFunction and has no prototype slot | No own this or arguments; it is not a constructor. |
For language behavior, revisit call, apply, and bind and Arrow functions. Here the point is the engine representation behind the behavior.
Practical memory and performance habits
Most closures are fine. Use this internals model when a heap snapshot, allocation profile, or performance trace points at a real problem. Then look for long-lived roots: event listeners, timers, caches, promises that never settle, or module-level arrays of callbacks.
function mountPanel(root, report) { const state = { clicks: 0 }; function onClick() { state.clicks += 1; report(state.clicks); } root.addEventListener("click", onClick); return () => root.removeEventListener("click", onClick);} const events = [];const root = { listeners: new Set(), addEventListener(_type, fn) { this.listeners.add(fn); }, removeEventListener(_type, fn) { this.listeners.delete(fn); }, click() { for (const fn of this.listeners) fn(); },}; const cleanup = mountPanel(root, (count) => events.push(count));root.click();root.click();cleanup();root.click();console.log(events.join(","));console.log(root.listeners.size);Line 3 creates a small state object. Line 7 registers a callback that closes over it. Line 8 returns a cleanup function. After line 22 runs, the root no longer stores the callback, so the closure and its state can become collectible when nothing else references them.
- Keep captured data narrow: capture an ID or a small summary instead of a whole response object when that is all the callback needs.
- Clean up listeners, timers, observers, and subscriptions when UI or server resources unmount.
- Use heap snapshots to find retainers before changing code; names like
ContextandJSFunctionare clues. - Measure first for performance. Sharing SFI and feedback details rarely matter unless a profiler says a hot path does.
Common misconceptions
- “A closure copies every variable.” It keeps access through a context; the context contains slots for variables V8 decided must be context-allocated.
- “Every function object owns its own code.” Many JSFunction objects can share one SharedFunctionInfo.
- “If my returned closure does not read a value, it cannot keep it alive.” A sibling closure can force a shared context that the returned closure keeps reachable.
- “Bound functions modify the original function.” They are wrappers with bound target, this, and arguments.
- “V8 internals are JavaScript semantics.” The spec promises lexical behavior; V8's object names are implementation details.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| Function value | The callable object you hold in a variable | Not the same thing as the shared bytecode metadata. |
| Closure | A function object plus access to its creation context | Not a frozen copy of every value in scope. |
| Context | Heap storage for variables that must outlive a stack frame | Not created for every local variable. |
| Feedback | Runtime evidence attached through feedback cells and vectors | Not JavaScript semantics and not a source-level type annotation. |
Practice exercises
Read the snippet and type the three printed values.
function makeClosure(seed) {
return function inner(extra) {
return seed + extra;
};
}
const first = makeClosure(1);
const second = makeClosure(2);
console.log(first === second);
console.log(first(10));
console.log(second(10));It prints false, then 11, then 12. The functions share code metadata but are different JSFunction objects with different contexts.
What two values does the context-sharing snippet print?
function outer() {
const big = ["notes", "chart", "photo"];
const readBig = () => big.length;
const answer = () => 1;
return { readBig, answer };
}
const closures = outer();
console.log(closures.answer());
console.log(closures.readBig());It prints 1 and 3. The internals lesson adds that both returned closures can point at the same V8 context.
Type the three printed values from the bound-function snippet.
function total(fee, tax) {
return this.base + fee + tax;
}
const bill = total.bind({ base: 100 }, 20);
console.log(bill(5));
console.log(bill.length);
console.log(bill.name);It prints 125, 1, and bound total. The wrapper stores this, prepends 20, and leaves tax to be supplied later.
A factory creates a huge big array, creates an unused sibling closure that reads it, and returns a different closure. Name one safe fix before this becomes a production leak.
Good fixes include removing the unused sibling closure, moving big into the function that actually needs it, splitting scopes, or cleaning up the long-lived callback root.
Finish the factory so it prints order:Asha. Then say which variable is closed over.
function makeLabel(prefix) {
// return an arrow that adds ":" and a name
}
const label = makeLabel("order");
console.log(label("Asha"));function makeLabel(prefix) {
return (name) => prefix + ":" + name;
}
const label = makeLabel("order");
console.log(label("Asha"));The arrow closes over prefix. Each call to makeLabel would create a new JSFunction and a context for that prefix.
After the cleanup function runs in the practical example, how many listeners remain?
function mountPanel(root, report) {
const state = { clicks: 0 };
function onClick() {
state.clicks += 1;
report(state.clicks);
}
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}
const events = [];
const root = {
listeners: new Set(),
addEventListener(_type, fn) { this.listeners.add(fn); },
removeEventListener(_type, fn) { this.listeners.delete(fn); },
click() { for (const fn of this.listeners) fn(); },
};
const cleanup = mountPanel(root, (count) => events.push(count));
root.click();
root.click();
cleanup();
root.click();
console.log(events.join(","));
console.log(root.listeners.size);The remaining listener count is 0. In a real app, returning and calling cleanup functions prevents long-lived DOM or subscription roots from retaining closures.
Check your understanding
Question 1 of 8What is the main job of
SharedFunctionInfoin V8?Choose an answer to see the explanation.
Question 2 of 8What does this closure identity snippet print?
Read the code, then predictfunction make(seed) { return function add(x) { return seed + x; }; } const a = make(1); const b = make(1); console.log(a === b); console.log(a(4), b(4));Choose an answer to see the explanation.
Question 3 of 8Two sibling closures created during one
outer()call point at the same V8FunctionContext. What practical risk follows?Choose an answer to see the explanation.
Question 4 of 8What does this bound-function snippet print?
Read the code, then predictfunction total(fee, tax) { return this.base + fee + tax; } const bill = total.bind({ base: 100 }, 20); console.log(bill(5)); console.log(bill.length); console.log(bill.name);Choose an answer to see the explanation.
Question 5 of 8What does this arrow-function snippet print?
Read the code, then predictconst shop = { label: "Kochi counter", makeReader() { return () => this.label; }, }; const reader = shop.makeReader(); console.log(reader.call({ label: "Mumbai counter" })); console.log("prototype" in reader);Choose an answer to see the explanation.
Question 6 of 8In the V8 feedback-cell probe, what changed after a second closure of the same inner literal was created?
Choose an answer to see the explanation.
Question 7 of 8Which statement keeps JavaScript semantics separate from V8 implementation details?
Choose an answer to see the explanation.
Question 8 of 8What should you do when a profile suggests closures retain too much memory?
Choose an answer to see the explanation.
Key takeaways
- Every evaluation of a function literal creates a new
JSFunction, while V8 can share oneSharedFunctionInfo. - A closure's context is heap storage for closed-over variables; sibling closures from one outer call can share it.
- A returned closure can keep a large value alive through a shared context even when only a sibling closure reads that value.
- Feedback cells and vectors collect runtime evidence; V8 can mark one-closure and many-closures cases.
- Bound functions are wrappers; arrows are function objects with lexical
this, no ownarguments, and no constructor prototype.
Remember the one-liner.
A closure is a callable object pointing at shared code and, when needed, a live context of captured variables.
Next: Prototypes inside the engine, where the same map and feedback ideas explain inherited property lookups.