Topics

Jump to a topic.

Search questions

Find a question by title, tag, or summary.

What is an execution context, and what does the call stack hold?

Questions in this chapter (9)
  • Draft
  • medium
  • Askedvery often
  • Verified October 2026

Answer

An execution context is a specification device that tracks the evaluation of one chunk of code: a script, a module, eval code, or a function call. At any moment an agent has exactly one running execution context, the top of the execution context stack (what developers call the call stack).

Every context carries:

  • Function: the function object being run (null for a script or module).
  • Realm: the realm whose intrinsics (Array, Object, …) and global the code uses.
  • ScriptOrModule: where the code came from.
  • LexicalEnvironment: the Environment Record used to resolve identifiers. It changes as the code enters and leaves blocks.
  • VariableEnvironment: the record that holds var declarations; fixed for the context's lifetime.
  • Plus code evaluation state, enough to suspend and resume the code.

Lifecycle. A function call creates a context (PrepareForOrdinaryCall): a new Function Environment Record whose outer link is the function's [[Environment]], then the context is pushed and becomes the running one. Declarations are instantiated, the body runs, and on return the context is popped, making the caller's context current again. A script or module gets its context first, and the stack is empty when the event loop is idle, which is why timer and promise callbacks start on a fresh stack.

Not LIFO everywhere. Generator and async function contexts are suspended and resumed later, so the spec says transitions are "usually" last-in-first-out.

Two caveats worth saying aloud. Contexts are purely a specification mechanism: engines use real stack frames, registers, and heap-allocated environments however they like. And the call stack (who called whom) is different from the scope chain (where code was written).

Example

const frames = () =>
  new Error().stack
    .split('\n')
    .slice(2) // drop the "Error" line and this helper's own frame
    .map((line) => line.trim().split(' ')[1])
    .slice(0, 3);

function a() { return b(); }
function b() { return c(); }
function c() { return frames(); }

console.log(a().join(' <- ')); // → c <- b <- a (V8's stack format)

Pitfalls

  • Saying each function "has" one context. Each call creates a new one, so recursion creates many.
  • Mixing up the stack and the heap. Contexts are popped on return, but the Environment Records they referenced survive if a closure captured them.
  • Describing the stack as engine-visible truth. Stack traces are a host feature (Error.prototype.stack isn't in the spec), and engines may inline calls or drop frames.
  • Thinking await blocks the stack. The function's context is suspended and removed; other code runs; the continuation resumes later on an almost empty stack.

Follow-ups

What is the "global execution context"?
The context created to evaluate a script, whose environments are the realm's Global Environment Record. A module gets its own context with a Module Environment Record. Each script or module evaluation is a separate context, pushed when it starts.
What is the difference between LexicalEnvironment and VariableEnvironment?
var bindings go in the VariableEnvironment, which never changes in a context. LexicalEnvironment is what lookups start from, and it is swapped for a new block-level record when execution enters a block.
What happens to the stack when an exception is thrown?
Contexts are popped one by one while the throw completion propagates, until a try/catch handles it. If none does, the stack unwinds to the host.

Deep dive

How it works

At startup the host runs InitializeHostDefinedRealm, which creates a realm and an initial context with no function. Each script then runs in a new context whose LexicalEnvironment and VariableEnvironment are the realm's global environment, after GlobalDeclarationInstantiation has created its bindings.

A call to f() does roughly this:

  1. PrepareForOrdinaryCall makes a context for f, copying the function's realm and script, and sets both environment fields to a new Function Environment Record.
  2. The caller's context is suspended, and the new one is pushed.
  3. FunctionDeclarationInstantiation binds parameters and hoists declarations.
  4. The body is evaluated; a block statement temporarily replaces LexicalEnvironment with a new inner record and restores it on exit.
  5. On completion, the context is popped and the result goes to the caller.

You can observe the push and pop order from the example's output: frames() is the innermost, and each caller waits below it.

Edge cases

Generators keep their context alive between calls. gen.next() pushes the saved context back and pauses it again at the next yield. While it is paused, it is not on the stack, but its locals live on:

function* gen() {
  const reply = yield 1;
  console.log(reply);
}
const it = gen();
it.next(); // runs to the first yield, then the context is suspended
it.next('resumed'); // → resumed

Async functions work the same way: await suspends the context, and a promise job later resumes it from the job queue. After the first await, the original caller is no longer below it. A stack trace taken there shows only the resumed function, plus engine-added async frames in some engines.

Stack depth is limited by the host. Exceeding it throws a RangeError in V8 and JavaScriptCore and an InternalError in SpiderMonkey. Neither type nor size is defined by the spec; see the stack overflow question.

Real-world scenario

Reading a stack trace is reading the execution context stack from top (where the error was created) down to the entry point. For production errors in async code this is why traces are often shorter than the logical call chain: the awaiting caller is not on the stack when the continuation runs. Debuggers like Chrome DevTools reconstruct "async stack traces" from promise bookkeeping, not from the real stack.

Exercises

Order of events

function inner() {
  console.log('inner');
}
function outer() {
  console.log('outer start');
  inner();
  console.log('outer end');
}
outer();
console.log('after');

Two calls, one function

function counter() {
  let count = 0;
  return () => ++count;
}
const a = counter();
const b = counter();
a();
console.log(a(), b());