cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Functions & closures inside the engine

Learn how V8 represents functions with SharedFunctionInfo, JSFunction objects, Contexts, feedback cells, bound functions, and arrows.

By the end, you can
  • 01
    Separate 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.
  • 02
    Read what a closure retainsConnect closed-over variables to heap Context objects, sibling closures, and the large values those contexts can keep reachable.
  • 03
    Recognize 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.

Definition

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.

Real-life analogyA family recipe book everyone reads

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.

Four internal pieces you will see in this lesson
PieceHow many?What it holds
SharedFunctionInfoShared by evaluations of the same function literalName, formal parameter count, bytecode/code metadata, source positions, ScopeInfo, and feedback metadata.
JSFunctionOne runtime function objectThe object JavaScript code can call, compare, pass around, bind, or store in a variable.
ContextOne heap environment when a scope needs closed-over storageThe runtime slots that hold closed-over values such as seed, count, or a retained array.
FeedbackCell / FeedbackVectorRuntime feedback for tiering and inline cachesIndirection 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 one function literal becoming three closures
Step 0 of 11Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Teaching model, then V8 proof

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.

SharedFunctionInfo: the shared recipe

V8's function-architecture document says SharedFunctionInfo stores context-independent data: bytecode, ScopeInfo, source positions, and feedback metadata. That data can be shared because it does not depend on which outer call created the closure.

Node-only proof: two closures share one SFIJavaScript
function makeClosure(seed) {  return function inner(extra) {    return seed + extra;  };} const first = makeClosure(1);const second = makeClosure(2);%DebugPrint(first);%DebugPrint(second);
Verified runtime fact

The lesson test runs this in one Node 22 process with --allow-natives-syntax. It parses two DebugPrint blocks, asserts the two DebugPrint: function addresses are different, and asserts their shared_info: addresses are equal.

Read the fields in plain language. shared_info names the reusable recipe. context points at the environment for one closure. formal_parameter_count, kind, and source code come from the shared function description.

This is why “function object” can mean two different things in casual conversation. JavaScript code holds the JSFunction. Engine engineers often talk about the heavier shared metadata separately.

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.

Step through sibling closures sharing one context
Step 0 of 7Ready
Your turn: follow the blue line

Watch two closures created in one outer call share one context object. This is a teaching replay backed by the V8 DebugPrint proof below.

Running in
  1. script
Next: line 8
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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());
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Node-only proof: sibling closures share one FunctionContextJavaScript
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);
Verified runtime fact

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.

Real-life analogyA shared notebook keeps every page

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.

Playground: which closure makes big stay alive?
Retention scenario sourceJavaScript
function outer() {  const big = new Array(1_000_000).fill("invoice");  const unused = () => big.length;  return () => 1;} const keep = outer();console.log(keep());
Shared contextretains big
returned closure contextFunctionContext shared by siblings
retained slotsbig
`big` alive?yes

A sibling closure reads `big`; V8 creates one shared FunctionContext, so the returned closure can keep that context alive too.

Try it yourself

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.

This is a teaching model; it does not inspect your browser's engine. The Node/V8 probes below prove the same relationship with DebugPrint and WeakRef evidence.
Which version keeps big reachable?
Code shapeV8 context shapeMemory consequence
No inner function uses bigThe returned arrow can point at the script contextThe local big can become collectible after the call.
The returned function uses bigA FunctionContext must store bigThe closure keeps big reachable while the function is reachable.
Only a sibling function uses bigV8 can create one shared FunctionContext for siblingsThe returned sibling can still keep that context, and therefore big, alive.
Node-only proof: no sibling versus sibling contextJavaScript
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);
Node-only WeakRef probe for retained bigJavaScript
function 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());})();
Verified runtime fact

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.

Node-only proof: one-closure and many-closures feedback cellsJavaScript
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);
Verified runtime fact

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.

Per closure or shared?
  • The value returned by each call to makeClosure().
  • The inner function's name, bytecode, parameter count, and ScopeInfo.
  • The seed value from one factory call.
  • A large big array used only by a sibling closure.
  • The warmed binary-op feedback for many closures of one literal.
  • bound_target_function, bound_this, and bound_arguments.
  • An arrow's kind: ArrowFunction and missing prototype slot.
Try it yourself
0 of 7 correct

Sort each internal fact by where it belongs in the model. The explanations connect the cards back to the V8 probes.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Bound functions prepend stored argumentsPop out in the code editor (opens in a new tab)JavaScript
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);
Node-only proof: JSBoundFunction fieldsJavaScript
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.

Arrows capture this and arguments from outsidePop out in the code editor (opens in a new tab)JavaScript
const 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);}
Node-only proof: ArrowFunction with no prototype slotJavaScript
const arrow = () => this;%DebugPrint(arrow);
Normal, bound, and arrow functions
KindInternal shapePractical behavior
Normal functionUsually has a prototype slot and may be a constructorOwn this is chosen by the call form or bind.
Bound functionA JSBoundFunction wrapper points at a target, bound this, and bound argumentsCalling it prepends stored arguments; construction behavior depends on the target.
Arrow functionA function object whose kind is ArrowFunction and has no prototype slotNo 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.

Cleanup makes the long-lived closure unreachablePop out in the code editor (opens in a new tab)JavaScript
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 Context and JSFunction are 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.
Similar ideas that are easy to mix up
IdeaWhat it meansDo not confuse it with
Function valueThe callable object you hold in a variableNot the same thing as the shared bytecode metadata.
ClosureA function object plus access to its creation contextNot a frozen copy of every value in scope.
ContextHeap storage for variables that must outlive a stack frameNot created for every local variable.
FeedbackRuntime evidence attached through feedback cells and vectorsNot JavaScript semantics and not a source-level type annotation.

Practice exercises

Exercise 1 · Warm-upPredict closure identity

Read the snippet and type the three printed values.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeRead the shared context output

    What two values does the context-sharing snippet print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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());

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePredict a bound wrapper

      Type the three printed values from the bound-function snippet.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the retained value bug

        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.

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeWrite a tiny factory

          Finish the factory so it prints order:Asha. Then say which variable is closed over.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function makeLabel(prefix) {
            // return an arrow that adds ":" and a name
          }
          
          const label = makeLabel("order");
          console.log(label("Asha"));
          Exercise 6 · ChallengeApply it to a real app

          After the cleanup function runs in the practical example, how many listeners remain?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            Function internals quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is the main job of SharedFunctionInfo in V8?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this closure identity snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function 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.

            3. Question 3 of 8Two sibling closures created during one outer() call point at the same V8 FunctionContext. What practical risk follows?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does this bound-function snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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);

              Choose an answer to see the explanation.

            5. Question 5 of 8What does this arrow-function snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            6. 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.

            7. Question 7 of 8Which statement keeps JavaScript semantics separate from V8 implementation details?

              Choose an answer to see the explanation.

            8. 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 one SharedFunctionInfo.
            • 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 own arguments, 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.

            CompleteFrontend Clear concepts. Working examples.