This chapter follows Hidden classes and inline caches, which was about how engines store objects. This one is about how they track running code. The terms come from the ECMAScript specification, because that is where hoisting, closures, and this are defined; the sections on stack depth and stack traces use V8 and Node to show what those rules look like in practice.
What an execution context holds
An execution context is the specification’s record of one piece of code that is running or could resume. There is one for the global script, one for each module, one for each function call, and one for each eval. Each holds:
- Code evaluation state — where execution is, so it can pause and resume.
- Function — the function being run, or
nullfor scripts and modules. - Realm — the set of global objects and built-ins this code uses.
- ScriptOrModule — the script or module the code came from.
- LexicalEnvironment and VariableEnvironment — where name lookups start. The second exists for
vardeclarations. - PrivateEnvironment — the
#privatenames visible inside a class body.
this is not stored directly in the context. A function call’s this lives in the function’s environment record, which the LexicalEnvironment chain leads to. Arrow functions do not get their own this binding, so a lookup of this inside one continues to the enclosing function’s environment. The this keyword covers the rules that decide its value.
Environment records and the scope chain
Variables live in environment records. Each block, function body, module, and the global scope gets one, and each record has a reference to its outer record. Looking up a name starts at the current record and follows the outer references until the name is found or the chain ends, which raises a ReferenceError.
const rate = 0.2;
function price(net) { const gross = net * (1 + rate); if (gross > 100) { const discount = 5; return gross - discount; } return gross;}If scope.js is loaded as a module, the chain has four records while return gross - discount runs:
flowchart TB block["Block: discount"] -->|"outer"| fn["Function price: net, gross"] fn -->|"outer"| mod["Module: rate, price"] mod -->|"outer"| global["Global: globalThis properties such as console"]
The chain is fixed by where the code is written, not by who calls it. That is lexical scope: price sees rate because it is written inside the scope that declares rate, wherever it is called from. In a classic script, top-level const and let live in a declarative part of the global record, which is why they do not become properties of globalThis the way top-level var does. Scope and closures covers scoping rules from the language side.
Hoisting and the temporal dead zone
Before a function body runs its first statement, the engine creates all of its declarations. The specification calls this declaration instantiation; “hoisting” is the informal name. Each kind of declaration starts in a different state:
| Declaration | Created at entry | Initial state |
|---|---|---|
function f() {} |
Yes | The function object, ready to call |
var x |
Yes | undefined |
let x, const x, class X |
Yes | Uninitialized |
console.log(typeof greet); // 'function'console.log(count); // undefinedconsole.log(limit); // ReferenceError: Cannot access 'limit' before initialization
function greet() {}var count = 1;let limit = 10;An uninitialized binding exists but cannot be read or written. The time between entering the scope and executing the declaration is the temporal dead zone (TDZ). “Temporal” is literal: what matters is when the access runs, not where it is written.
function read() { return value;}
// read() here would throw: value is still in its TDZlet value = 1;read(); // 1Variables and declarations covers when to use which keyword.
Closures keep environments alive
A function object stores a reference to the environment record it was created in. When it is called later, its new function environment gets that stored record as its outer reference. That is all a closure is: the function keeps its birthplace’s environment reachable.
function createCounter() { let count = 0; return () => { count += 1; return count; };}
const next = createCounter();next(); // 1next(); // 2createCounter has returned, and its execution context is gone from the stack. Its environment record — holding count — is still reachable through next, so it stays on the heap. Each call to createCounter creates a new record, which is why two counters do not share a count.
This is also where closures meet memory. An environment stays alive as long as any function created in it is reachable. Finding memory leaks shows how that can keep a large value alive that no live function uses.
The call stack, frame by frame
The call stack, which the specification calls the execution context stack, holds the contexts of every call that has started and not finished. The top one is the running execution context. A call pushes a new context; a return pops it, and the one below resumes where it left off.
function formatTotal(items) { return `Total: ${sum(items)}`;}
function sum(items) { return items.reduce(add, 0);}
function add(a, b) { return a + b;}
formatTotal([1, 2, 3]);While add runs for the first time, the stack is, from top to bottom:
| Position | Context | Waiting at |
|---|---|---|
| Top (running) | add |
— |
reduce (built-in) |
the callback call | |
sum |
items.reduce(...) |
|
formatTotal |
sum(items) |
|
| Bottom | Global script | formatTotal(...) |
add returns and is popped. reduce calls it again, pushing a new context for the next element, and so on until reduce returns to sum. The stack is last in, first out, and only the context on top ever runs.
Engines implement this with machine stack frames that hold the return address, arguments, and locals. An engine frame and a specification context are not always one to one: when the optimizing compiler inlines add into sum, there is no separate machine frame for add. The engine still reports one in stack traces and the debugger, because it keeps enough information to reconstruct the frames it removed.
Stack overflow
The stack has a fixed size, so recursion without a base case eventually fails:
let depth = 0;
function recurse() { depth += 1; recurse();}
try { recurse();} catch (error) { console.log(error.message, depth);}On Node 24 with the default stack size, this printed Maximum call stack size exceeded 12546. A function with eight parameters and a local array reached about 4,600 calls, because each frame was larger. The limit is in bytes, not calls, and it differs between engines, browsers, and machines.
Node can raise it with node --stack-size=2000 depth.js (in kilobytes), which roughly doubled the count. Setting it above what the operating system gives the thread crashes the process instead of throwing, so it is not a fix for deep recursion. The fix is to stop using the call stack as the data structure: keep pending work in an array.
function sumTree(root) { let total = 0; const pending = [root]; while (pending.length > 0) { const node = pending.pop(); total += node.value; for (const child of node.children) pending.push(child); } return total;}The specification has required proper tail calls in strict mode since ES2015, which would let a function that ends in return f(x) reuse its frame. Only JavaScriptCore implements them, so recursion depth is not portable to rely on.
Suspended contexts: generators and async functions
Most contexts run once, from push to pop. Generators and async functions are the exception: their contexts can be suspended and later resumed.
When a generator reaches yield, or an async function reaches await, its context is removed from the stack with its code evaluation state intact, and control returns to whoever called it. When the generator’s next() is called, or the awaited promise settles, the context is pushed back onto the stack and continues from the same point. Its environment, with all local variables, was never discarded.
That explains a difference that confuses many people. A callback runs later on a fresh stack, so a try block around the code that scheduled it is long gone:
try { setTimeout(() => { throw new Error('lost'); }, 0);} catch (error) { // never runs: the callback throws on a new stack, after this block has finished}An await resumes the same context, still inside its try block, so the error is caught:
async function save() { try { await upload(); } catch (error) { // runs when upload() rejects }}Generators in JavaScript and Async/await in JavaScript cover these from the language side.
Realms
Every context belongs to a realm: one global object and one full set of built-ins, such as Array, Object, and Promise. A page has one realm per window or iframe; each worker has its own; Node’s vm module creates new ones.
Objects can pass between realms, but their built-ins are different objects. instanceof compares against one realm’s constructor, so it fails for an array from an iframe:
const frame = document.createElement('iframe');document.body.append(frame);
const list = new frame.contentWindow.Array();console.log(list instanceof Array); // falseconsole.log(Array.isArray(list)); // trueArray.isArray checks what the object is, not which realm made it, so it works across realms. The same applies to checks against Error and Object: when values can come from another frame, prefer checks that do not depend on one realm’s constructors.
Reading stack traces
When an Error is created, the engine records the current stack in its stack property. In V8, each line is one frame, the innermost first:
async function load() { await null; return parse();}
function parse() { throw new Error('bad input');}
async function main() { await load();}
main().catch((error) => console.log(error.stack));Error: bad input at parse (/tmp/trace.js:7:9) at load (/tmp/trace.js:3:10) at async main (/tmp/trace.js:11:3)Read it top down: parse threw, called by load, which was awaited by main. The async frame is not on the real stack anymore — main was suspended — but V8 reconstructs async frames for await chains. It cannot do that for callbacks scheduled with setTimeout or for .then() chains, so those traces start at the callback. Chrome DevTools fills in such gaps in its call stack pane while it is open.
A few V8-specific controls are useful:
Error.stackTraceLimitsets how many frames are captured. The default is 10, so a deep trace is cut off; raise it while debugging.Error.captureStackTrace(target, fn)adds astackto any object and hides frames abovefn, which keeps helper functions out of traces.- The
causeoption,new Error('save failed', { cause: error }), keeps the original error and its trace when you wrap it. See Error handling in JavaScript.
The format of error.stack is not standardized. Firefox and Safari print name@file:line:column instead of at name (file:line:column), so parse it only with a library that knows each format.
Check your understanding
Question 1 of 9
0 of 9 checked