Functions & closures inside the engine
Learn how V8 represents functions with SharedFunctionInfo, JSFunction objects, Contexts, feedback cells, bound functions, and arrows.
- 01Tell a function object from its shared codeExplain why each run of a function literal makes a new
JSFunction, while all of them share oneSharedFunctionInfothat holds the code. - 02Say what a closure keeps in memoryExplain how outside variables go into a
Context, how sibling functions share one, and how that can keep a large value in memory. - 03Spot special kinds of functionsDescribe feedback cells, the wrapper that
bindmakes, and what makes arrow functions different. Keep V8's own names apart from what JavaScript promises.
What a function looks like inside the engine
The engine is the program that runs your JavaScript. Chrome and Node use an engine called V8. This lesson uses V8 version 12.4, the one that comes with Node 22.
You may already know what a closure is: a function that can still use variables from the place where it was made. This lesson opens up V8 and shows the objects it builds to make functions and closures work.
Keep two things apart the whole way:
- What JavaScript promises. Every engine must behave this way.
- How V8 keeps that promise. These are V8's own objects and names. Other engines use different ones.
Every function you can call is an object. V8 calls that object a JSFunction. It points to two other things:
- The function's code and facts about it, like its name. V8 calls this the
SharedFunctionInfo. Many functions can share one. - For a closure, a small storage object for the outside variables it uses. V8 calls this a
Context.
These lessons explain the JavaScript side first: Closures, Function objects, call, apply, and bind, and Arrow functions. Scope analysis explains how V8 decides which variables need a context. This lesson builds on them.
A family keeps one recipe book. Each person reads the same recipe and cooks their own meal. Each cook picks their own amount of rice. The recipe is shared. The amount belongs to one cook.
- In real life: One recipe that everyone reads
- In JavaScript: The
SharedFunctionInfo: the code and facts about it - In real life: Each cook making one meal from it
- In JavaScript: A
JSFunction: one function object - In real life: Each cook's own amount of rice
- In JavaScript: A
Context: the outside variables for one call - In real life: Notes on which recipes get made a lot
- In JavaScript: Feedback: notes V8 keeps to make busy code faster
The table lists the four parts you'll meet. Read each row as: "this part, how many there are, and what it holds."
| Part | How many are there? | What does it hold? |
|---|---|---|
SharedFunctionInfo | One for each function literal in your code. Every function made from that literal shares it. | The code and facts about it: the name, the number of parameters, the bytecode, and where it sits in the file. |
JSFunction | A new one each time a function literal runs. | The function itself: what your code calls, compares with ===, passes around, or keeps in a variable. |
Context | One for each call that has outside variables a closure still needs. | The outside variables a closure uses, like seed, count, or a large array. |
FeedbackCell and FeedbackVector | Made as the code runs. Sometimes many functions share them. | V8's notes on how the code runs: which function a call reached, what shape of object it read, and how often it ran. |
Previous in this module: Fast arrays & elements kinds. Next in this module: Prototypes inside the engine.
A new function object each time
A function literal is the piece of code that makes a function, like function inner() { } or () => 1. Each time JavaScript runs that piece of code, V8 makes a brand-new JSFunction.
So two functions can run the same code and still be two different objects. === on them gives false.
The step-through below is a small model written in plain JavaScript. It acts out what V8 does. It is not V8 itself.
Step through a model of one function literal running three times. The shared code is made once. Each run makes a new function object and a new 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);Here is what the model does, line by line:
- Line 1 makes one shared object for the code. It stands for the
SharedFunctionInfo. - Lines 17 to 19 call
evaluateFunctionLiteralthree times, like running a function literal three times. - Each call makes a new context on line 6 and a new function object on line 8.
- Line 20 prints
true: all three function objects point to the same shared code. - Line 21 prints
true: the function objects themselves are different. - Line 22 prints
true: each function object has its own context.
The step-through is a model, so you can see the idea. The next section checks the same idea in real V8. The lesson's tests run that check in Node and compare which objects are the same and which are different.
Contexts hold the outside variables
When a function is called, its local variables usually live in a scratch area for that one call. The engine calls it a stack frame. When the call ends, the stack frame is thrown away.
But an inner function may still need an outer variable after the outer call has ended. V8 can't keep that variable in the stack frame. So it puts it in a Context: a small object in long-lived memory (the heap) that stays as long as something points to it.
Before the code runs, V8 reads it and decides which variables need a context. When the outer function is called, V8 makes the context. For a function call, V8 names it a FunctionContext.
Watch two functions made in one call share one context. This step-through is a model. The Node check below shows the same thing in real V8.
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 the array big. Line 3 makes readBig, which reads big. Line 4 makes answer, which does not read big. Two functions made in the same outer call are called siblings.
outer() makes one context. Both functions point to it, even answer, which never reads big.In real V8, both siblings point to the same FunctionContext. The code below checks that.
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);The tests print both functions. Their shared_info addresses are different, because they come from two different arrow functions. Their context addresses are the same, because one call to outer() made them both.
What a closure keeps alive
The engine frees memory on its own. This is called garbage collection. It frees an object only when nothing your program still uses can reach it. An object that can still be reached is reachable, and it stays in memory.
A closure keeps alive everything it can reach. In V8 that chain often looks like this:
- Your program holds the function object.
- The function object points to its context.
- The context holds every variable that was put in it, even ones this function never reads.
That last point matters for memory, as the next example shows.
Two classmates share one notebook. One of them draws a large picture on a page. The other only needs a short note on another page. When the second classmate takes the notebook home, the drawing goes home too. It is in the same notebook.
- In real life: One notebook shared by two classmates
- In JavaScript: One
FunctionContextshared by two sibling functions - In real life: One page holds a large drawing
- In JavaScript: A large array,
big, stored in the context - In real life: One classmate takes the notebook home
- In JavaScript: The returned function, which never reads
big - In real life: The drawing goes home too
- In JavaScript:
bigstays in memory
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 reads big, so big goes in one context that both siblings share. The returned function keeps that context, so big stays in memory too.
Only a sibling reads big. A sibling reads big, so big goes in one context that both siblings share. The returned function keeps that context, so big stays in memory too. In this model, big stays in memory.
The table sums up the three cases from the playground. Read each row as: "when the code looks like this, V8 does this, so this happens to big."
| When the code looks like this | What V8 does | What happens to big |
|---|---|---|
No inner function reads big | It makes no context for big. The returned arrow points to the context of the whole file instead. | big can be freed once the call ends. |
The returned function reads big | It puts big in a FunctionContext. | big stays in memory as long as the returned function does. |
Only a sibling function reads big | It puts big in one FunctionContext that both siblings share. | The returned function keeps that context, so big stays in memory too. |
The next code checks this in real V8. noSibling has no inner function that reads big. withSibling has one, called unused, on line 8. Both return () => 1.
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);In the printout, the first function points to a ScriptContext. That is the context of the whole file, and it holds nothing from noSibling. The second function points to a FunctionContext made by the call to withSibling.
The next code checks whether big really stays in memory. It uses two tools:
- A
WeakRefpoints to an object without keeping it alive. Itsderef()method gives back the object, orundefinedonce the object has been freed. globalThis.gc()starts garbage collection right away. It only exists when you start Node with the--expose-gcsetting.
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());})();Line 28 prints no sibling alive false: big was freed. Line 29 prints with sibling alive true: the returned function still holds the context, and the context still holds big.
big, so nothing keeps it and it can be freed. Right: an unused sibling reads big, so big goes into a context, and the returned function keeps that context.The tests check two facts. First, the function from noSibling points to a ScriptContext, and the function from withSibling points to a FunctionContext. Second, after garbage collection, the first WeakRef is empty, and the second one still holds big.
To hunt real memory problems, go on to Memory management and Finding memory leaks. The usual cause is not that a closure exists. It is that something long-lived, like an event listener, a timer, or a cache, still holds the closure.
Feedback cells: notes shared by many closures
While your code runs, V8 takes notes on how it runs. These notes are called feedback. For example:
- which function a call ended up calling;
- what shape of object a property was read from;
- whether a
+only ever saw small whole numbers; - how often the function has run.
V8 uses these notes to make busy code faster. It keeps them in two places:
- A
FeedbackVectoris the list of notes itself. - A
FeedbackCellsits between the function object and that list. It lets V8 make the list only when the code is used, and share it when that is safe.
The code below makes one factory that creates one function, and a second factory that creates two functions from the same literal. Then it calls each function 40 times, so V8 has something to note. Calling code many times like this is called warming it up.
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);A factory keeps one feedback cell for the function literal inside it. The cell says whether that literal has made one function so far, or many.
The tests check three things in the printout:
- Line 8 prints
oneFactorywithFeedbackCell[one closure]. - Line 21 prints
manyFactorywithFeedbackCell[many closures]. - Lines 22 and 23 print
firstandsecond. They have the samefeedback vectoraddress but differentcontextaddresses.
You don't need to change your code because of this. It shows that V8 can share code, and sometimes notes, while still giving each closure its own variables. The type feedback and inline caches lessons explain the notes in more depth.
- The function that each call to
makeClosure()returns. - The inner function's name, bytecode, number of parameters, and variable names.
- The
seedvalue from one call to the factory. - A large array,
big, that only a sibling function reads. - V8's notes on
x + n, after two functions from one literal both ran 40 times. bound_target_function,bound_this, andbound_arguments.- An arrow's
kind: ArrowFunctionand its missingprototype.
Put each fact where it belongs: one per function object, in the shared code and notes, in a context, or in a special kind of function. Each card explains why once you place it.
Bound functions and arrow functions inside V8
fn.bind(thisValue, ...args) makes a new function that always calls fn with that this and those first arguments. It does not change fn. It makes a wrapper: a new function object that stores three things and then calls the original. V8 calls this wrapper a JSBoundFunction. It stores:
- the function to call, as
bound_target_function; - the
thisvalue to use, asbound_this; - the arguments to put first, as
bound_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);In plain words, line by line:
- Line 5 makes
bill. It saves{ base: 100 }asthisand20as the first argument,fee. - Line 6 calls
bill(5). The5becomestax, sototalreturns 100 + 20 + 5 and it prints125. - Line 7 prints
1. A function'slengthis how many parameters it expects. Onlytaxis still open. - Line 8 prints
bound total. A bound function's name starts withbound.
bill is a new wrapper. It stores total, the this object, and the first argument 20. Calling bill(5) runs total with all of them, so it gives 100 + 20 + 5 = 125.function total(fee, tax) { return this.base + fee + tax;} const bound = total.bind({ base: 10 }, 1);%DebugPrint(bound);The printout says JSBoundFunction and shows the three saved parts. The original total is not changed.
Arrow functions are different. They are still function objects, but V8 marks their kind as ArrowFunction. An arrow function:
- has no
thisof its own. It uses thethisof the code around it. - has no
argumentsof its own. It uses theargumentsof the function around it. - has no
prototypeproperty. - can't be called with
new.
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);}In plain words, line by line:
- Line 4 makes an arrow inside
makeReader. It usesmakeReader'sthis, which isshop. - Line 9 makes an arrow that reads
arguments[0]ofmakeFirst. - Line 14 tries to give the arrow a new
thiswith.call(). It still printsKochi counter. - Line 15 prints
Asha, the first argument passed tomakeFirston line 13. - Line 16 prints
false: the arrow has noprototypeproperty. - Line 18 calls the arrow with
new. That throws, and line 20 printsTypeError.
const arrow = () => this;%DebugPrint(arrow);The printout shows kind: ArrowFunction and function prototype: <no-prototype-slot>. That means V8 did not even leave room for a prototype.
The table compares the three kinds. Read each row as: "this kind of function, what V8 builds for it, and how it acts."
| Kind of function | What does V8 build? | How does it act? |
|---|---|---|
| Normal function | A function object. It usually has a prototype property and can be used with new. | Its this depends on how you call it, or on bind. |
| Bound function | A JSBoundFunction wrapper. It stores the function to call, the this to use, and the first arguments. | It puts the saved arguments before the ones you pass. It works with new only if the original function does. |
| Arrow function | A function object whose kind is ArrowFunction. It has no room for a prototype. | It has no this or arguments of its own, and it can't be used with new. |
For how these act in everyday code, see call, apply, and bind and Arrow functions. This section showed what V8 builds behind them.
Habits for memory and speed in real code
Most closures are fine, and you don't need to think about contexts every day. Use this lesson when a tool shows a real problem:
- a heap snapshot, a DevTools list of every object in memory and what keeps it there;
- an allocation profile, which shows where your code makes new objects;
- a performance trace, which shows where time goes.
Then look for something long-lived that still holds a closure: an event listener, a timer, a cache, a promise that never finishes, or a top-level array 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);In plain words, line by line:
- Line 2 makes a small
stateobject. - Line 3 makes
onClick, a closure that usesstate. - Line 7 adds
onClicktoroot's listeners. Nowrootholds the closure. - Line 8 returns a cleanup function that removes
onClickagain. - Lines 20 and 21 click twice. Line 22 runs the cleanup. The click on line 23 runs no listener.
- Line 24 prints
1,2, and line 25 prints0listeners.
After line 22, root no longer holds onClick. When nothing else points to it, the closure and its state can be freed.
- Keep only what you need in a closure. If a callback only needs an ID, keep the ID, not the whole server response.
- Remove listeners, timers, observers, and subscriptions when a part of the page goes away or a server task ends.
- Find what keeps an object alive in a heap snapshot before you change code. Names like
ContextandJSFunctionare clues. - Measure speed before you change it. Shared code and shared notes rarely matter unless a profiler points at busy code.
Common misconceptions
- "A closure copies every variable." It doesn't copy them. It points to a context, and the context holds the variables V8 decided to put there.
- "Every function object has its own copy of the code." Many function objects can share one
SharedFunctionInfo. - "If my returned function never reads a value, it can't keep that value alive." A sibling that reads it can put it in a shared context. The returned function keeps that context, and so the value.
- "bind changes the original function." It makes a new wrapper that stores the function,
this, and the first arguments. - "V8's objects are part of JavaScript." JavaScript only promises how closures act. Names like
JSFunctionbelong to V8, and other engines use different ones.
The table puts side by side four ideas that are easy to mix up. Read each row as: "this idea, what it is, and what it is not."
| Idea | What is it? | What is it not? |
|---|---|---|
| Function value | The function object you keep in a variable and call. | It is not the shared code. Many function values can share one copy of the code. |
| Closure | A function object that can still reach the context where it was made. | It is not a copy of every variable around it. |
| Context | A small object that holds the outside variables a closure still needs after a call ends. | It is not made for every local variable. Most local variables never go in one. |
| Feedback | V8's notes on how code runs, kept in feedback cells and feedback vectors. | It is not part of JavaScript, and it is not a type you write in your code. |
Practice exercises
Read the code and type the three values it prints.
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 two functions share the same code, but they are two different function objects, each with its own context and its own seed.
What two values does this code 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 then 3. In V8, both functions also point to the same context, the one made by the call to outer().
Type the three values this code prints.
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 saves this and puts 20 first. The 5 you pass becomes tax, the one parameter still open.
A factory makes a huge array, big. It also makes an unused sibling function that reads big. Then it returns a different function. Name one safe fix so big doesn't stay in memory.
Any of these works: remove the unused sibling function; move big into the function that really needs it; split the code into separate functions so they don't share a context; or remove the long-lived listener or timer that holds the returned function.
Finish the factory so it prints order:Asha. Then say which outside variable the arrow uses.
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 uses the outside variable prefix. Each call to makeLabel makes a new function object and a new context that holds that call's prefix.
After the cleanup function runs in this code, how many listeners are left?
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);No listeners are left: the count is 0. In a real app, returning and calling cleanup functions stops long-lived things, like the page or a subscription, from holding closures.
Check your understanding
Question 1 of 8What does the
SharedFunctionInfohold in V8?Choose an answer to see the explanation.
Question 2 of 8What does this code with two closures 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 8One call to
outer()makes two sibling functions. In V8, both point to the sameFunctionContext. What can go wrong?Choose an answer to see the explanation.
Question 4 of 8What does this bound function 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 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 lesson's feedback cell check, what changed when a factory made a second function from the same literal?
Choose an answer to see the explanation.
Question 7 of 8Which sentence keeps what JavaScript promises apart from how V8 works?
Choose an answer to see the explanation.
Question 8 of 8A memory tool shows that closures keep too much in memory. What should you do?
Choose an answer to see the explanation.
Key takeaways
- Each time a function literal runs, V8 makes a new
JSFunction. Many of them can share oneSharedFunctionInfo, which holds the code. - A
Contextholds the outside variables a closure still needs. Siblings made in one outer call share one context. - A returned function can keep a large value in memory through a shared context, even when only a sibling reads that value.
- Feedback is V8's notes on how code runs. A feedback cell records whether a literal made one function or many.
- A bound function is a wrapper that stores the function,
this, and the first arguments. An arrow has nothis,arguments, orprototypeof its own, and can't be used withnew.
Remember the one-liner.
A closure is a function object that points to shared code and, when it needs one, to a context that holds its outside variables.
Next: Prototypes inside the engine. It shows how V8 uses the same kind of notes to find a property on a parent object quickly.