JavaScript Execution Context and the Call Stack: The Mental Model You Need
Every JavaScript bug you have ever shipped — every undefined is not a function, every infinite loop, every weird this, every stack overflow — ultimately traces back to one mental model: the execution context and the call stack.
If you can picture what happens to your variables and function calls from the moment the JS engine reads your file to the moment it shuts down, you stop guessing about closures, scope, hoisting, and this. They all just fall out of the model.
This is Part 1 of the JavaScript Fundamentals series. We start here because the next 17 articles all build on what you'll read in the next 10 minutes.
What is an execution context? #
An execution context is the environment in which a piece of JavaScript is evaluated. Every time the engine runs code, it creates a context that holds three things:
- A variable environment — the bindings (
var,let,const, function declarations) defined in this scope. - A lexical environment — a reference to the outer environment where this code was written (not called). This is what makes closures work.
- A
thisbinding — whatthisrefers to inside this code.
JavaScript creates two kinds of execution contexts:
- The global execution context — created once, when your program starts. There is exactly one.
- Function execution contexts — created every time a function is called. There are as many as the function-call depth of your program.
The two phases of every context #
When the engine enters a new execution context, it runs in two phases:
Phase 1: Creation (memory allocation) #
Before a single line of your code runs, the engine scans the scope and:
- Allocates memory for every
vardeclaration (initialized toundefined). - Allocates memory for every function declaration (the full function body is hoisted).
- Allocates memory for
letandconstdeclarations, but leaves them uninitialized — this is the temporal dead zone you have probably heard about. - Sets up the
thisbinding and the outer-environment reference.
This is the phase that gives us hoisting. We will go much deeper into hoisting in Part 2.
Phase 2: Execution (running the code) #
Now the engine steps through your code line by line, assigning values, calling functions, and creating new function execution contexts as it encounters function calls.
Look at this snippet:
console.log(name); // undefined (not ReferenceError!)
console.log(greet); // [Function: greet]
var name = 'Sam';
function greet() { return `Hi ${name}`; }
Why does console.log(name) log undefined instead of throwing? Because in the creation phase, the engine already allocated memory for name and greet. var bindings get undefined until the execution phase assigns them. Function declarations get their full body. By the time line 1 runs, both already exist in memory.
The call stack #
The call stack is a LIFO (last-in-first-out) data structure the engine uses to track which execution context is currently active.
When the engine starts your program, it pushes the global execution context onto the stack. When a function is called, a new function execution context is pushed on top. When the function returns, its context is popped off.
At any moment, the context at the top of the stack is the one running.
┌────────────────────────┐ ← top (currently running)
│ greet() context │
├────────────────────────┤
│ formatName() context │
├────────────────────────┤
│ main() context │
├────────────────────────┤
│ Global context │
└────────────────────────┘
A worked example #
Let's trace what happens step by step:
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(5);
Here is the call stack at each moment:
1. Program starts — global context pushed:
[ Global ]
2. printSquare(5) is called:
[ Global, printSquare ]
3. Inside printSquare, square(5) is called:
[ Global, printSquare, square ]
4. Inside square, multiply(5, 5) is called:
[ Global, printSquare, square, multiply ]
5. multiply returns 25 and pops off:
[ Global, printSquare, square ]
6. square returns 25 and pops off:
[ Global, printSquare ]
7. console.log(25) runs, printSquare returns, pops off:
[ Global ]
8. Program ends, Global pops off. Stack is empty.
Every synchronous JS program is this same pattern of pushing and popping. There is exactly one stack, and exactly one context running on it at any time. That is why JavaScript is called single-threaded.
Stack overflow: when the model breaks #
The call stack has a finite size. Each browser/runtime caps it at around 10,000-15,000 frames. When you exceed that, you get the classic error:
function recurse() {
return recurse();
}
recurse(); // Uncaught RangeError: Maximum call stack size exceeded
This is not a JavaScript bug — it is the call stack working exactly as designed. Each call pushes a new frame; nothing pops because nothing returns; eventually the stack runs out of memory.
The fix for legitimate deep recursion is to convert recursion into iteration (using a while-loop and your own data structure) or use trampolining (returning a function instead of calling it, then a driver loop invokes the returned function).
Inspecting the call stack yourself #
Open any browser DevTools, click the Sources tab, set a breakpoint inside a nested function, and reload. The right-hand panel shows the live call stack — frames you can click into to inspect each context's variables. This is the same model the engine uses; the debugger just exposes it.
You can also dump the stack programmatically:
function inner() {
console.trace('Where am I?');
}
function middle() { inner(); }
function outer() { middle(); }
outer();
// Where am I?
// at inner
// at middle
// at outer
// at <anonymous>
console.trace() reads the current call stack and prints every frame from the call site up to the global context.
What about async code? #
If JavaScript only has one call stack and runs one thing at a time, how does setTimeout, fetch, and Promise work?
Short answer: those APIs are not actually JavaScript. They are provided by the host environment (the browser or Node.js). When you call setTimeout(cb, 1000), the timer is handed off to the host; the JavaScript engine immediately moves on. When the timer fires, the host pushes cb into a task queue, and a separate component called the event loop waits for the call stack to be empty before pulling tasks off the queue and pushing them onto the stack.
We will unpack the event loop, microtasks, and macrotasks in detail in Part 7. For now, hold this picture: every async callback eventually gets its own execution context and lands on the same single call stack.
Common gotchas #
Gotcha 1: Function expressions are NOT hoisted like declarations.
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () { return 'hi'; };
In the creation phase, var sayHi is initialized to undefined. The function body is only assigned when execution reaches line 2. Calling undefined() throws.
Gotcha 2: let and const exist but are in the temporal dead zone.
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
The engine does know about x (it was registered in the creation phase), but accessing it before the let statement throws on purpose.
Gotcha 3: Each function call gets its OWN context — including its own variables.
function make() {
let id = Math.random();
return id;
}
make() === make(); // almost always false
The two calls create two separate function execution contexts. Each gets its own id binding.
Gotcha 4: Arrow functions do not create their own this binding.
const obj = {
arrow: () => console.log(this),
regular() { console.log(this); },
};
obj.arrow(); // global / window / undefined (in strict mode)
obj.regular(); // obj
We will go deep on this in Part 5.
Why this mental model matters #
Once you internalize execution contexts and the call stack, a lot of "weird JavaScript" stops being weird:
- Hoisting is just the creation phase doing its job.
- Closures are functions remembering their lexical environment after the outer context has popped off the stack.
thisis just one of the three things every execution context tracks — its value depends on how the function was called.- Stack traces in error messages are literally the call stack at the moment the error was thrown.
- Async code is the same model — one stack, one running context — with an event loop feeding callbacks in between synchronous work.
Every topic in the rest of this series builds on this picture. Bookmark it.
Recap #
- JavaScript runs every piece of code inside an execution context that tracks variables, the outer scope, and
this. - There is one global execution context plus a function execution context per active call.
- Each context runs in two phases: creation (memory allocation, hoisting) and execution (assigning values, calling functions).
- The call stack is the engine's record of which contexts are active. Synchronous code is just push, push, pop, pop.
- The stack has a finite size — that is what stack overflows are.
- Async APIs sidestep the stack via the event loop, which we cover later in the series.
Next up: Hoisting Demystified — we dig into why var and function declarations behave so differently from let and const, and what "the temporal dead zone" actually is.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


