Execution Context Stack and Realm: Call Stack Structure and Cross-Realm Traps
Every function call creates an independent execution context—this is the foundation of JavaScript's runtime model
JavaScript engines do not execute code by "scanning line by line." Instead, they maintain an Execution Context Stack (EC Stack, also called the Call Stack). Each time a function is called, the engine pushes a new Execution Context (EC) onto the top of the stack. When the function finishes, that EC is popped off, and control returns to the EC below it. This push/pop cycle governs variable visibility, this values, and execution order.
Realm is a separate concept: it is a complete JavaScript runtime environment containing its own global object and a full set of built-in constructors. The main document, an <iframe>, and a Web Worker each hold an independent Realm. When objects cross Realm boundaries, instanceof silently fails—this is not a bug but an intentional consequence of the specification design.
🔹 Level 1 · What You Need to Know
The Call Stack: Order of Function Entry and Exit
The call stack is a Last-In-First-Out (LIFO) data structure. When a program starts, the engine creates the Global Execution Context (Global EC) and pushes it to the bottom of the stack, where it stays for the entire program lifetime until the page closes or the process exits.
function bar() {
console.log('bar executing');
}
function foo() {
bar();
console.log('foo executing');
}
foo();
The call stack changes for this code:
- Program starts → push Global EC
foo()is called → push foo ECbar()is called → push bar ECbarfinishes → pop bar ECfoofinishes → pop foo EC- Program ends → pop Global EC
Each EC is independent: local variables in foo are not visible to bar (unless through a closure, covered in Chapter 19).
Stack Overflow: The Consequence of Unbounded Recursion
The call stack has a depth limit. Chrome (V8) allows roughly 10,000–15,000 frames, Firefox (SpiderMonkey) roughly 50,000, and Node.js roughly 10,000–12,000 (varies with the number of parameters in the recursive function). Exceeding the limit throws:
RangeError: Maximum call stack size exceeded
The most common trigger is recursion without a termination condition:
// Wrong: no termination condition
function countdown(n) {
console.log(n);
countdown(n - 1); // never stops
}
countdown(5); // RangeError
// Correct: termination condition present
function countdown(n) {
if (n < 0) return; // base case
console.log(n);
countdown(n - 1);
}
countdown(5); // 5, 4, 3, 2, 1, 0
The most direct way to debug the call stack is to set a breakpoint in the browser DevTools "Sources" panel; the "Call Stack" panel on the left shows the complete EC stack at that moment.
Realm: An Independent JavaScript Runtime Environment
A Realm is a container that holds:
- A global object (
windowin the browser main document,selfin a Worker,globalin Node.js; useglobalThisfor a unified reference) - A set of built-in objects (
Array,Object,Function,Promise, etc.) - A set of built-in prototype chains
Key conclusion: The Array in different Realms is a different constructor function. An array created inside an iframe will fail an instanceof Array check in the main document:
// Main document
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeArray = new iframe.contentWindow.Array(1, 2, 3);
console.log(iframeArray instanceof Array); // false ← cross-Realm failure
console.log(Array.isArray(iframeArray)); // true ← safe cross-Realm check
console.log(Object.prototype.toString.call(iframeArray)); // "[object Array]" ← also safe
Array.isArray uses the [[IsArray]] internal operation. It recursively unwraps Proxy wrappers and checks the internal slot directly, without traversing any prototype chain, so it is immune to Realm isolation. This is the specification's intentional fix for cross-Realm scenarios.
🔸 Level 2 · How It Actually Works
The Three Core Components of an Execution Context
The ECMAScript specification defines that every execution context contains the following core state components (specific EC types may add more):
| Component | Meaning | Primary Use Case |
|---|---|---|
| LexicalEnvironment | Current lexical environment; lookup starting point for let/const/function/class |
Variable lookup, TDZ |
| VariableEnvironment | Environment for var declarations; usually the same as LexicalEnvironment |
var hoisting |
| ThisBinding | The this value bound to this EC |
Method calls, arrow functions |
| Realm Record | The Realm this EC belongs to | Accessing built-in objects |
| ScriptOrModule | The Script or Module Record currently running | ESM import.meta |
LexicalEnvironment and VariableEnvironment only diverge inside try...catch blocks and with statements—a catch block creates a new LexicalEnvironment (to bind the error variable) while VariableEnvironment stays unchanged. In 99% of cases they point to the same Environment Record.
Full Structural Diagram of the Call Stack
┌─────────────────────────────────────────────────────────────┐
│ EC Stack (Call Stack) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ bar EC (top - currently executing) │ │
│ │ ├─ LexicalEnvironment → bar's ER │ │
│ │ ├─ VariableEnvironment → bar's ER │ │
│ │ └─ ThisBinding → undefined (strict) / global (non) │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ foo EC │ │
│ │ ├─ LexicalEnvironment → foo's ER │ │
│ │ ├─ VariableEnvironment → foo's ER │ │
│ │ └─ ThisBinding → undefined / global │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ Global EC (bottom - always present) │ │
│ │ ├─ LexicalEnvironment → GlobalEnvironmentRecord │ │
│ │ ├─ VariableEnvironment → GlobalEnvironmentRecord │ │
│ │ └─ ThisBinding → globalThis │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Full Contents of a Realm
┌─────────────────────────────────────────────────────────────┐
│ Realm Record │
│ │
│ [[Intrinsics]] → a map storing all built-in objects │
│ ├─ %Array% → Array constructor │
│ ├─ %Object% → Object constructor │
│ ├─ %Function% → Function constructor │
│ ├─ %Promise% → Promise constructor │
│ └─ ... (~200 built-in objects) │
│ │
│ [[GlobalObject]] → global object (window / global / self) │
│ [[GlobalEnv]] → GlobalEnvironmentRecord │
│ [[TemplateMap]] → template literal cache │
└─────────────────────────────────────────────────────────────┘
Each Realm's [[Intrinsics]] table is independent. This is why iframe.contentWindow.Array !== window.Array, even though they behave identically.
How Generator Function ECs Suspend
Generators are a special case in execution context management. When gen.next() is called, the Generator's EC is pushed onto the stack. When execution reaches yield, the EC is not popped and destroyed—it is saved into the GeneratorObject's internal slot [[GeneratorContext]] and then removed from the stack. The next call to next() restores this EC to the top of the stack to continue execution.
function* counter() {
console.log('segment 1');
yield 1; // EC suspends here, saved to GeneratorObject
console.log('segment 2');
yield 2; // EC suspends again
console.log('segment 3');
return 3; // EC completes, GeneratorObject state → 'completed'
}
const gen = counter(); // creates GeneratorObject; EC has not run yet
gen.next(); // push EC, run to yield 1, EC suspends → { value: 1, done: false }
gen.next(); // restore EC, run to yield 2, EC suspends → { value: 2, done: false }
gen.next(); // restore EC, run to return, EC destroyed → { value: 3, done: true }
A Generator EC's lifecycle is not tied to a single call; it survives across multiple next() calls and is only truly destroyed when done: true.
Tail Call Optimization (TCO) and EC Reuse
A tail call is one where the last operation of a function is a call to another function, and the result is returned directly without any further computation. ECMAScript 2015 mandates in strict mode that engines must perform tail call optimization: reuse the current EC rather than create a new one, preventing stack growth.
'use strict';
// True tail call: the last step is return factorial(n-1, acc*n), nothing after
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, acc * n); // tail call: engine can reuse current EC
}
// Not a tail call: return n * factorial(n-1) requires multiplication after the call
function badFactorial(n) {
if (n <= 1) return 1;
return n * badFactorial(n - 1); // non-tail-call: current EC must be preserved
}
Implementation status (2025): Safari (JavaScriptCore) fully implements TCO; V8 (Chrome/Node.js) explicitly does not implement TCO, citing high maintenance cost and debugging difficulty, even in strict mode. Therefore, you cannot rely on TCO to avoid stack overflow. The correct approach is to rewrite recursion as iteration, or use the Trampoline technique.
🔺 Level 3 · What the Specification Says
Specification Section 9.4: Execution Contexts
ECMAScript specification (ECMA-262, Section 9.4) defines an execution context as:
An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation.
State components of every execution context:
| Component | Type | Description |
|---|---|---|
| code evaluation state | — | Current state (executing/suspended/completed); used by Generators and Async functions |
| Function | Object / null | The function object currently executing (null if at script/module level) |
| Realm | Realm Record | The associated Realm Record |
| ScriptOrModule | Script / Module Record | The associated Script or Module Record |
| LexicalEnvironment | Environment Record | — |
| VariableEnvironment | Environment Record | — |
| PrivateEnvironment | PrivateEnvironment Record | Used for class private fields |
The specification describes the call stack as: maintaining an execution context stack, where the top entry is the running execution context. All spec operations that refer to "the current lexical environment" mean "the LexicalEnvironment of the running execution context."
Specification Section 9.3: Realms
The specification defines these core operations:
CreateRealm() algorithm (simplified):
- Create a new Realm Record
realmRec - Call
CreateIntrinsics(realmRec)— populate the[[Intrinsics]]map (creates all built-in object prototypes and constructors, ~200 entries) - Set
realmRec.[[GlobalObject]]to undefined (set in a later step) - Set
realmRec.[[GlobalEnv]]to undefined (set in a later step) - Set
realmRec.[[TemplateMap]]to an empty list - Return
realmRec
SetRealmGlobalObject(realmRec, globalObj, thisValue) algorithm:
- If
globalObjis undefined, create a new ordinary object as the global object - Set
realmRec.[[GlobalObject]]toglobalObj - Create a new
GlobalEnvironmentRecordbased onglobalObj - Set
realmRec.[[GlobalEnv]]to that GlobalEnvironmentRecord
Internal Structure of GlobalEnvironmentRecord
GlobalEnvironmentRecord is a composite of two sub-records:
GlobalEnvironmentRecord
├─ [[ObjectRecord]] → ObjectEnvironmentRecord (bound to globalObj)
│ └─ var declarations → become properties on globalObj
└─ [[DeclarativeRecord]] → DeclarativeEnvironmentRecord
└─ let/const/class → stored in DeclarativeRecord
This is why var x = 1 at global scope makes window.x === 1 true, but let x = 1 at global scope leaves window.x === undefined. The two declaration forms land in different sub-records within GlobalEnvironmentRecord.
Specification text (9.1.1.4 GetBindingValue, simplified): GlobalEnvironmentRecord looks up a variable by first checking [[DeclarativeRecord]] (for let/const), and if not found, checking [[ObjectRecord]] (for var and global object properties).
The [[IsArray]] Internal Operation and Cross-Realm Detection
Specification Section 7.2.2 defines the IsArray(argument) abstract operation:
- If
argumentis an Array exotic object → returntrue - If
argumentis a Proxy exotic object → callIsArray(argument.[[ProxyTarget]])(recursively unwrap) - Otherwise → return
false
The key point: IsArray checks the internal [[Class]] slot of the object (an Array exotic object is characterized by its special [[DefineOwnProperty]]), not the prototype chain. By contrast, instanceof walks OrdinaryHasInstance → the prototype chain, which is necessarily Realm-specific.
The specification text of Array.isArray:
- Return ? IsArray(arg).
Just one line—all the magic lives in the IsArray abstract operation.
💎 Level 4 · Edge Cases and Traps
Trap 1: Cross-iframe instanceof Silently Fails
Symptom: The parent page passes an array to an iframe; code inside the iframe uses instanceof Array to detect it, gets false, and takes the wrong code path.
// Parent page (parent.html)
const iframe = document.createElement('iframe');
iframe.src = 'child.html';
document.body.appendChild(iframe);
iframe.onload = function() {
const arr = [1, 2, 3];
iframe.contentWindow.processData(arr);
};
// Code inside child.html
function processData(data) {
if (data instanceof Array) {
// Array created in parent — instanceof fails here!
console.log('is array:', data.length);
} else {
console.log('not an array'); // ← this branch executes
}
}
Root cause: The [1,2,3] from the parent was created using the parent Realm's %Array%; the Array inside the iframe is from a different Realm's %Array%. instanceof checks: arr.__proto__ === Array.prototype — here Array.prototype is the iframe's version, while arr.__proto__ is the parent's version, and they are different objects.
Correct approach:
function processData(data) {
if (Array.isArray(data)) { // safe: uses [[IsArray]] internal operation
console.log('is array:', data.length);
}
// Or use Object.prototype.toString (also Realm-agnostic)
if (Object.prototype.toString.call(data) === '[object Array]') {
console.log('is array');
}
}
The same issue applies to instanceof Error, instanceof Map, and every other constructor across Realm boundaries. Always avoid instanceof for cross-Realm checks; use toString or dedicated static methods like Array.isArray.
Trap 2: New Realms Created by the Node.js vm Module
Node.js's vm module runs code inside an isolated sandbox; each vm.createContext() creates a new Realm.
const vm = require('vm');
const sandbox = { x: 10 };
const context = vm.createContext(sandbox);
// Create an array inside the sandbox
const result = vm.runInContext('[1, 2, 3]', context);
console.log(result instanceof Array); // false ← cross-Realm
console.log(Array.isArray(result)); // true ← safe
console.log(result.constructor === Array); // false ← cross-Realm
console.log(result.constructor.name); // 'Array' ← same name, different object
// Prototype of the object created inside the sandbox
const resultProto = vm.runInContext(
'Object.getPrototypeOf([1,2,3])',
context
);
console.log(resultProto === Array.prototype); // false ← different Realm prototypes
Practical engineering impact: In plugin systems or sandbox execution of user code in Node.js, any object returned from the sandbox may trigger this problem. The solution is to serialize (JSON.stringify/JSON.parse) data across Realm boundaries, or use structuredClone.
Trap 3: TCO Engine Differences Lead to Portability Problems
ECMAScript 2015 requires TCO in strict mode, but V8 explicitly refuses to implement it, causing the same code to behave differently in Safari and Chrome:
'use strict';
// In Safari: handles any depth of n (TCO active, no stack growth)
// In Chrome/Node.js: RangeError when n > ~12000
function sum(n, acc = 0) {
if (n === 0) return acc;
return sum(n - 1, acc + n); // tail call
}
console.log(sum(100000)); // Safari: 5000050000; Chrome: RangeError
Portable rewrite—use a loop instead of tail recursion:
function sum(n) {
let acc = 0;
for (let i = n; i > 0; i--) {
acc += i;
}
return acc;
}
// Or the Trampoline technique (useful for more complex mutual recursion)
function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}
const sumTramp = trampoline(function _sum(n, acc = 0) {
if (n === 0) return acc;
return () => _sum(n - 1, acc + n); // return a function instead of calling directly
});
console.log(sumTramp(100000)); // 5000050000, safe on any engine
Trap 4: Call Stack Depth Varies Across Engines
Call stack depth is not a fixed value mandated by the ECMAScript specification—each engine decides it independently:
| Engine | Version | Typical Stack Depth | Factors |
|---|---|---|---|
| V8 (Chrome/Node.js) | Chrome 120 / Node 20 | 10,000–15,000 | Number of function parameters (more params = larger frame = lower depth) |
| SpiderMonkey (Firefox) | Firefox 121 | ~50,000 | Optimization level |
| JavaScriptCore (Safari) | Safari 17 | ~65,000 (unlimited with TCO) | TCO implementation |
| Hermes (React Native) | 0.73 | ~5,000 | Mobile memory constraints |
// Measure the current engine's call stack depth
function measureStackDepth(depth = 0) {
try {
return measureStackDepth(depth + 1);
} catch (e) {
return depth;
}
}
console.log(`Current engine call stack depth: ${measureStackDepth()}`);
// V8: ~13000; SpiderMonkey: ~50000; JSC: ~65000
This variance means that deeply recursive code working fine in Firefox can crash immediately in Hermes (React Native). Cross-platform applications should target the most conservative engine; a safe ceiling is around 3,000 frames.
Trap 5: Suspended Generator ECs and Memory Impact
An incomplete Generator object holds a suspended EC; the EC holds its LexicalEnvironment; the LexicalEnvironment may hold large variables. If the Generator object is forgotten (neither next() nor return() is called again), the suspended EC cannot be garbage-collected.
function* heavyGenerator() {
const bigBuffer = new Uint8Array(10 * 1024 * 1024); // 10 MB
yield 'midpoint';
// bigBuffer remains held by the EC after yield, until the Generator finishes
yield bigBuffer.length;
}
// Danger: only one next() call; Generator is stuck in the middle; bigBuffer cannot be GC'd
const gen = heavyGenerator();
gen.next(); // { value: 'midpoint', done: false }
// If gen is then forgotten, 10 MB is leaked
// Correct: either iterate to completion or explicitly close
const gen2 = heavyGenerator();
gen2.next();
gen2.return(); // force-close the Generator; EC destroyed; memory released
For async iterators (for await...of), the specification requires that engines call the iterator's return() method on a break, ensuring Generator resources are released. However, manually created Generators that are abandoned mid-way do not trigger return() automatically.
Summary
-
Every function call creates an Execution Context (EC) pushed onto the call stack; when the function returns, the EC is popped. The call stack has an engine-imposed depth limit: V8 allows roughly 10,000–15,000 frames, SpiderMonkey roughly 50,000.
-
An Execution Context contains three core components: LexicalEnvironment (for
let/constscope), VariableEnvironment (forvarscope), and ThisBinding (thethisvalue). In the vast majority of cases the first two point to the same Environment Record. -
A Realm is an independent JavaScript runtime environment containing its own global object and a complete set of built-in constructors; iframes, Workers, and Node.js
vmcontexts each hold an independent Realm. -
Cross-Realm
instanceofchecks silently fail; the correct approach isArray.isArray()(which uses the[[IsArray]]internal operation) orObject.prototype.toString.call(). -
When a Generator function reaches
yield, its EC is saved to the GeneratorObject's internal slot and removed from the call stack; an incomplete Generator holds a live EC that prevents its LexicalEnvironment from being garbage-collected—abandoning a Generator mid-way requires callinggenerator.return()to explicitly release resources.