Execution Context & Call Stack

Difficulty: Intermediate

Every time JavaScript code runs, it executes inside an execution context - an abstract container that holds the information the engine needs to track and run a particular piece of code. Understanding execution contexts is fundamental because they explain how JavaScript manages variable scope, function calls, and the mysterious `this` keyword. There are two main types: the Global Execution Context (created once when your script starts) and Function Execution Contexts (created each time a function is called).

Each execution context goes through two phases. The Creation Phase happens before any code in that context runs. During this phase, three things are set up: the Variable Environment (all var declarations are registered and initialized to undefined, let/const are registered but placed in the TDZ, and function declarations are fully stored), the Scope Chain (a reference to the outer environment is established), and the `this` binding (the value of `this` is determined based on how the function was called). The Execution Phase then runs the code line by line, assigning values to variables and executing function calls.

The Call Stack is a LIFO (Last In, First Out) data structure that JavaScript uses to keep track of execution contexts. When your script starts, the Global Execution Context is pushed onto the stack. Whenever a function is called, a new Function Execution Context is created and pushed onto the top of the stack. When a function finishes executing (returns a value or reaches the end), its context is popped off the stack, and control returns to the context beneath it.

Visualizing the call stack is crucial for debugging. When you see a stack trace in an error message, you are literally reading the call stack from top to bottom - the top frame is where the error occurred, and each frame below is the function that called it. Browser DevTools let you pause execution and inspect the call stack at any point, which is invaluable for understanding program flow.

Stack overflow occurs when the call stack exceeds its maximum size, typically caused by infinite recursion - a function that calls itself without a proper base case. Each recursive call adds a new frame to the stack, and since the stack has a finite size (usually around 10,000-25,000 frames depending on the environment), eventually it runs out of space. Understanding the call stack helps you write correct recursive algorithms and debug stack overflow errors.

Code examples

Creation phase vs execution phase

console.log('--- Creation phase demo ---');
console.log(a);      // var: hoisted, initialized to undefined
// console.log(b);   // let: hoisted but in TDZ - ReferenceError
console.log(typeof greet); // function: fully hoisted

var a = 10;
let b = 20;

function greet() {
  return 'Hello!';
}

console.log(a);  // 10 - execution phase assigned the value
console.log(b);  // 20 - past the TDZ now
console.log(greet());

During the creation phase, var a is registered as undefined, let b enters the TDZ, and greet is fully available. During the execution phase, values are assigned in order. This two-phase process is what 'hoisting' actually is - it is not literally moving code, it is the creation phase registering declarations before execution begins.

Call stack visualization

function first() {
  console.log('first start');
  second();
  console.log('first end');
}

function second() {
  console.log('second start');
  third();
  console.log('second end');
}

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

first();

// Call stack states:
// 1. [global]            → first() called
// 2. [global, first]     → second() called
// 3. [global, first, second] → third() called
// 4. [global, first, second, third] → third logs & returns
// 5. [global, first, second] → second logs & returns
// 6. [global, first]     → first logs & returns
// 7. [global]            → done

Each function call pushes a new context onto the stack. third() finishes first (LIFO), then second(), then first(). The output clearly shows the nesting: functions start in order of calls but end in reverse order. This is why call stacks in error messages show the most recent call at the top.

Execution context and this binding

const obj = {
  name: 'Widget',
  getName() {
    // 'this' is determined by HOW the function is called
    return this.name;
  }
};

// Method call - this = obj
console.log(obj.getName());

// Extracted function - this = undefined (strict) or global (sloppy)
const fn = obj.getName;
// console.log(fn()); // undefined or TypeError in strict mode

// Explicit binding with call
console.log(fn.call({ name: 'Override' }));

// Arrow function captures enclosing this
const obj2 = {
  name: 'Gadget',
  getNameArrow: function() {
    const arrow = () => this.name; // captures obj2 as this
    return arrow();
  }
};

console.log(obj2.getNameArrow());

When getName is called as obj.getName(), this is obj. When extracted to a standalone variable, this loses its binding. Using .call() explicitly sets this. Arrow functions do not get their own this - they inherit it from the enclosing execution context, which is why obj2.getNameArrow() correctly returns 'Gadget'.

Stack overflow with recursion

// Correct recursion - has a base case
function factorial(n) {
  if (n <= 1) return 1;  // base case stops recursion
  return n * factorial(n - 1);
}

console.log(factorial(5));
// Stack: [global, fact(5), fact(4), fact(3), fact(2), fact(1)]
// fact(1) returns 1, then unwinds: 2*1=2, 3*2=6, 4*6=24, 5*24=120

// Infinite recursion - no base case
function infinite() {
  return infinite(); // never stops
}

try {
  infinite();
} catch (e) {
  console.log(e.message);
}

// Stack depth test
function countDepth(n) {
  try {
    return countDepth(n + 1);
  } catch (e) {
    return n;
  }
}
console.log('Max stack depth ~', countDepth(0));

factorial has a base case (n <= 1) that stops recursion, allowing the stack to unwind. infinite() has no base case, so it keeps pushing frames until the engine throws a RangeError. The countDepth function measures the approximate stack size by recursing until it hits the limit - typically 10,000-25,000 frames depending on the engine.

Key points

Concepts covered

global execution context, function execution context, creation phase, execution phase, variable environment, scope chain, this binding, call stack, stack overflow