The Execution Model

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

JavaScript does a surprising amount of work before your first line visibly runs. It builds a place for the code, puts that place on a stack, and decides which names already exist. Once you can see that hidden setup, hoisting, scope, closures, and stack overflows stop feeling like separate mysteries.

Execution contexts and the call stack

An execution context is the engine's work folder for one piece of running code: its local names, its link to outer names, and the spot where execution currently is. The whole script gets a global execution context first. Every function call creates a fresh function execution context.

Each context becomes a stack frame, one entry on the call stack, which is the current stack of running calls. Reuse the stack-of-plates picture from debugging: a function call puts a plate on top; returning removes it.

function addWinBonus(score) {
  console.log("2: addWinBonus is on top");
  return score + 25;
}
 
function reportScore(player, score) {
  console.log("1: reportScore is on top");
  const finalScore = addWinBonus(score);
  console.log(`3: ${player} finishes with ${finalScore}`);
}
 
reportScore("Mina", 40);
console.log("4: script is back on top");

1: reportScore is on top, 2: addWinBonus is on top, 3: Mina finishes with 65, 4: script is back on top.

While addWinBonus runs, it sits on top. reportScore waits below it. The global script waits below that. When addWinBonus returns, its frame pops off, and reportScore becomes the top frame again. One stack, one thread: while the top frame is running, no other JavaScript runs.

Creation phase, then execution phase

Every execution context has two passes. The creation phase is the setup pass where the engine registers declarations before running statements. The execution phase is the run pass where statements execute from top to bottom.

That is the full hoisting model. JavaScript is not dragging lines upward. It is doing a preflight inventory, then running the code.

console.log(formatScore("Rae", 18));
 
function formatScore(player, score) {
  return `${player}: ${score} points`;
}

Rae: 18 points — the function declaration was registered during creation, so it is callable when execution reaches the first line.

Different declarations get different creation-phase states:

  • Function declarations are created as callable function values.
  • let and const names are registered but locked until their declaration line runs.
  • var is old JavaScript; it is registered and initialized to undefined, which is why old code can read it too early.
console.log(legacyRank);
var legacyRank = 3; // old code only; modern code writes let/const
console.log(legacyRank);

undefined, then 3 — creation made the old var binding exist, and execution assigned the real value later.

For let and const, the early name exists but is untouchable. That locked zone is the temporal dead zone (TDZ), the time before a let or const declaration has executed.

console.log(teamName);
const teamName = "Falcons";

This produces ReferenceError: Cannot access 'teamName' before initialization. Loud beats silent: const and let force you to initialize before use.

The scope chain, with the right vocabulary

A lexical environment is the engine's name map for one scope plus a link to the next outer environment. The scope chain is that linked path of lexical environments the engine walks when it needs a name.

Lexical means "based on where the code is written." For normal variable names, JavaScript does not look at who called the function first. It looks in the current environment, then outward through the code's birthplaces.

const team = "Comets";
 
function makeReporter(player) {
  const intro = `${team} report for ${player}`;
 
  return function report(points) {
    const line = `${intro}: ${points} points`;
    console.log(line);
  };
}
 
const reportForIra = makeReporter("Ira");
reportForIra(31);

Comets report for Ira: 31 points.

When report runs, line is local. intro lives in the birthplace it remembers from makeReporter. team lives in the global environment. console is also found globally. That chain is why closures work: functions remember where they were born, and the remembered environment stays alive as long as some function can still use it.

Try it — trace the stack and the chain

Before you press Run, predict which function is on top for each log. Then type awardBadge("Bo") at the prompt and watch the same frames get created again.

javascript — live consolereal engine
⌘/Ctrl + Enter to run

The call stack explains when each function is running. The scope chain explains where each name comes from. You need both pictures: stack for control flow, scope chain for name lookup.

Stack overflow: recursion has a price

Recursion is a function calling itself. A recursive function needs a base case, a condition that stops the self-calls, or it keeps adding frames forever.

function countDown(points) {
  if (points === 0) {
    return "done";
  }
 
  return countDown(points - 1);
}
 
console.log(countDown(3));

donecountDown(3) calls countDown(2), then 1, then 0; the returns pop those frames back off.

A loop repeats inside one execution context. Recursion creates a new execution context for every call. That can be elegant, but it is not free.

function cheerForever(player) {
  return cheerForever(player);
}
 
cheerForever("Mina");

V8 stops this with RangeError: Maximum call stack size exceeded. The limit is not one magic number you should memorize; the lesson is the cost. Each unfinished call keeps a frame on the stack until it returns.

When the last frame returns, the call stack is empty. That empty-stack moment is the doorway to the event loop: only then can the runtime hand JavaScript the next waiting callback.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion