Skip to content

The Call Stack and Execution Contexts

intermediate11 min read

A Bug That Only Crashes in Production

Your teammate writes a recursive function to flatten deeply nested comment threads. It works perfectly in development with 3 levels of nesting. In production, a user has 12,000 nested replies. The page crashes with RangeError: Maximum call stack size exceeded.

The fix isn't "add more stack." The fix is understanding what the call stack actually is, why it has a limit, and how to restructure code that respects that limit.

The Mental Model

Mental Model

Think of the call stack as a stack of cafeteria trays. When you call a function, you place a tray on top. When that function calls another, another tray goes on top. When a function returns, its tray is removed. You can only ever interact with the top tray — the currently executing function. If you keep stacking trays without removing any, eventually the stack collapses.

JavaScript is single-threaded. It has one call stack. That single stack is why a long-running function freezes the entire page — nothing else can execute until the current function returns and its frame is popped.

How the Call Stack Actually Works

Every time you call a function, the engine creates an execution context — a data structure that holds everything needed to run that function:

  • Variable Environment — where let, const, and var declarations live
  • Lexical Environment — the scope chain (how closures work)
  • this binding — determined by how the function was called
  • Code — the actual instructions to execute

These execution contexts are pushed onto the call stack. The engine always executes whatever is on top.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(4);
Execution Trace
Step 1
Global execution context pushed
The engine starts by creating the global context
Step 2
printSquare(4) pushed onto stack
Stack: [global, printSquare]
Step 3
square(4) pushed onto stack
Stack: [global, printSquare, square]
Step 4
multiply(4, 4) pushed onto stack
Stack: [global, printSquare, square, multiply]
Step 5
multiply returns 16 — popped
Stack: [global, printSquare, square]
Step 6
square returns 16 — popped
Stack: [global, printSquare]
Step 7
console.log(16) pushed, executes, popped
Stack: [global, printSquare]
Step 8
printSquare returns — popped
Stack: [global]

Every step is deterministic. There is no concurrency, no parallelism, no surprises. The call stack is a pure LIFO (Last In, First Out) structure.

The Creation Phase vs. Execution Phase

Each execution context goes through two phases:

Creation phase (before any code in the function runs):

  1. Create the Variable Environment — var declarations are initialized to undefined, let/const declarations are allocated but uninitialized (the Temporal Dead Zone)
  2. Create the scope chain — link to the outer lexical environment
  3. Determine this

Execution phase:

  1. Execute code line by line
  2. Assign values to variables
  3. Call functions (which creates new execution contexts)

This two-phase process is why var hoisting works the way it does:

console.log(x); // undefined (created in creation phase)
var x = 5;

console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 10;

var x is initialized to undefined during the creation phase. let y is allocated but lives in the Temporal Dead Zone until the engine reaches the declaration during the execution phase.

Quiz
Why does console.log(x) print undefined instead of throwing an error when x is declared with var below it?

Stack Overflow: When the Stack Runs Out of Space

The call stack has a finite size. In V8 (Chrome/Node.js), it's roughly 10,000-15,000 frames depending on frame size. When you exceed it:

function recurse() {
  recurse(); // no base case
}

recurse(); // RangeError: Maximum call stack size exceeded

This isn't just a beginner mistake. It shows up in production with:

  • Deeply nested data structures (comment threads, file trees, org charts)
  • Mutually recursive functions where termination conditions have subtle bugs
  • Prototype chain loops created by accident

The fix: Trampoline pattern

Convert recursion into iteration using a trampoline — a loop that calls functions until one returns a value instead of another function:

// Dangerous: stack overflow on deep trees
function sumTree(node) {
  if (!node) return 0;
  return node.value + sumTree(node.left) + sumTree(node.right);
}

// Safe: iterative with explicit stack
function sumTreeSafe(root) {
  let sum = 0;
  const stack = [root];
  while (stack.length > 0) {
    const node = stack.pop();
    if (!node) continue;
    sum += node.value;
    stack.push(node.left, node.right);
  }
  return sum;
}

The iterative version uses the heap (which is much larger) instead of the call stack. The "stack" variable is just an array — it can grow to millions of entries without hitting the call stack limit.

Production Scenario: The Silent Stack Overflow

A real-world example that bites teams: JSON.stringify with circular references.

const user = { name: 'Alice' };
const team = { members: [user] };
user.team = team; // circular reference

JSON.stringify(user); // RangeError: Maximum call stack size exceeded

This crashes because JSON.stringify recursively traverses the object. When it encounters the cycle (user -> team -> members -> user -> ...), it recurses infinitely.

The fix isn't always obvious in production where circular references form through complex ORM relationships, React state containing DOM references, or cached objects with back-pointers:

// Option 1: Replacer function to break cycles
const seen = new WeakSet();
JSON.stringify(user, (key, value) => {
  if (typeof value === 'object' && value !== null) {
    if (seen.has(value)) return '[Circular]';
    seen.add(value);
  }
  return value;
});

// Option 2: structuredClone handles cycles natively
// (but throws on functions, DOM nodes, etc.)
Quiz
A function recursively processes a tree with 50,000 nodes. Each recursive call adds one frame to the stack. What happens?

Common Mistakes

What developers doWhat they should do
Thinking the call stack and the 'stack' in 'stack vs heap' are the same thing
The call stack is about function call order. Stack memory is about value storage. A function's local variables live in its stack frame, but objects referenced by those variables live on the heap.
The call stack holds execution contexts (function frames). The stack memory region holds local primitives. They're related but distinct concepts.
Believing tail call optimization (TCO) is available in all engines
ES2015 specified TCO, but V8 removed their implementation due to debugging concerns (stack traces become useless). Always use iterative patterns for potentially deep recursion.
Only Safari/JavaScriptCore implements TCO as of 2025. V8 and SpiderMonkey do not. Never rely on TCO to prevent stack overflow.
Assuming async functions don't use the call stack
await pauses the async function and pops its frame, but everything before the await runs synchronously on the call stack like any other function.
async functions use the call stack normally while executing synchronous code. They only yield the stack at await points.

Challenge: Predict the Stack

Challenge: Trace the Call Stack

function a() {
  console.log('a start');
  b();
  console.log('a end');
}

function b() {
  console.log('b start');
  c();
  console.log('b end');
}

function c() {
  console.log('c');
}

a();
Show Answer

Output: a start, b start, c, b end, a end

The call stack at each console.log:

  • a start: [global, a]
  • b start: [global, a, b]
  • c: [global, a, b, c]
  • b end: [global, a, b] (c has returned and been popped)
  • a end: [global, a] (b has returned and been popped)

This is pure synchronous, LIFO execution. Each function must complete before control returns to its caller. There are no surprises here — and that predictability is exactly the point. The call stack is deterministic.

Key Rules

Key Rules
  1. 1JavaScript has ONE call stack. All synchronous code runs on it. Nothing else can execute while a function is running.
  2. 2Each function call creates an execution context with its own variable environment, scope chain, and this binding — pushed onto the stack.
  3. 3The call stack has a hard size limit (~10K-15K frames). Recursion without a base case, or recursion on deep data, will throw RangeError.
  4. 4Convert deep recursion to iteration using an explicit stack (an array on the heap). Heap memory is orders of magnitude larger than the call stack.
  5. 5The call stack must be empty for the event loop to process the next task. A function that runs for 100ms blocks everything — rendering, user input, timers — for 100ms.
1/11