Back to JavaScript
JavaScript
medium
mid

How do execution contexts, the call stack, and async behavior fit together in JavaScript?

An execution context is the environment a piece of code runs in — it has a variable environment, scope chain, and `this`. The global context is created first; each function call pushes a new context on the call stack. Async behavior layers on top: Web APIs run async work off-thread and queue callbacks; the event loop pushes them back as new contexts when the stack is empty.

6 min read·~10 min to think through

This question bundles three connected ideas: execution context, async behavior, and the event loop.

Execution context

An execution context is the environment in which a chunk of JS code is evaluated. Each one holds:

  • Variable environment — declared variables, function declarations, the arguments object. (Hoisting happens here during the "creation phase".)
  • Scope chain — a reference to outer (lexical) environments, enabling closures.
  • this binding — determined by how the function was called.

There are three kinds: global (created once when the script loads), function (created on each call), and eval (rare).

The call stack manages contexts

js
function a() { b(); }
function b() { c(); }
function c() { console.log("done"); }
a();
// stack grows: [global] -> [global,a] -> [global,a,b] -> [global,a,b,c]
// then unwinds as each returns

The call stack is a stack of execution contexts. The top one runs; when a function returns, its context is popped. JS is single-threaded → exactly one context executes at a time.

Where async fits in

A function context runs synchronously to completion — it can't be paused mid-way by the engine (except await/generators, which voluntarily yield). So how do we get async?

  • An async Web API call (setTimeout, fetch) registers a callback and returns; its context pops.
  • The host does the work off-thread.
  • On completion, the callback is queued (microtask or macrotask).
  • When the call stack is empty (all contexts popped), the event loop takes a queued callback and pushes it as a new execution context.

So every async callback runs in its own fresh context, started by the event loop — never "in the middle of" the code that scheduled it.

Tying it together

js
function outer() {
  const msg = "hi";                       // outer's context
  setTimeout(() => console.log(msg), 0);   // closure over outer's scope chain
}
outer();  // outer's context pops immediately
// later: event loop creates a NEW context for the arrow fn;
// it still sees msg via the scope chain (closure) even though outer is gone

Senior framing

The connective insight: the call stack is contexts stacking up synchronously; the event loop is what re-introduces contexts asynchronously once the stack clears. Closures are why a deferred callback's new context can still reach variables from a context that already popped. Naming all three layers — context, stack, loop — and how they hand off is the senior-level answer.

Follow-up questions

  • What happens during the 'creation phase' of an execution context?
  • How does a closure keep variables alive after the outer context pops?
  • How is `this` determined for a given execution context?

Common mistakes

  • Confusing execution context with scope — context contains the scope chain, they aren't the same.
  • Thinking async callbacks resume the original context rather than starting a new one.
  • Forgetting hoisting is a creation-phase behavior of the context.

Edge cases

  • `await` and generators can suspend a context and resume it later — the exception to 'run to completion'.
  • Arrow functions don't create their own `this` binding; they inherit it.

Real-world examples

  • Closures in event handlers, the classic `var` in a loop bug, `this` losing context in callbacks.

Related questions