JavaScript Execution Contexts Explained
I explain what a JavaScript execution context holds, its two phases, the scope chain and the call stack, and why hoisting falls straight out of the model.

Most of us were taught that JavaScript runs top to bottom, one line at a time. Then you meet code like this:
console.log(flavor); // undefined — no error
var flavor = "mango";
makeCoffee(); // "brewing"
function makeCoffee() {
console.log("brewing");
}
If the engine truly read line by line, the first line should crash and calling a function before its declaration should fail. Neither happens. And just when you build a story around that, this shows up:
console.log(total); // ReferenceError: Cannot access 'total' before initialization
let total = 42;
Three reads of “too early”, three different outcomes: a value, a working call, an error. Nothing is moving to the top of anything. The difference is the execution context — the setup work the engine finishes before a single line of your code runs. I’ll build that model layer by layer: what a context is, what it holds, how the call stack manages them. By the end, hoisting and the var/let difference stop being trivia you memorise and become consequences you can derive.
Series: This is the first post in a loose JavaScript deep-dive series. The next post picks up the scope chain and follows it into closures.
On this page
- What an execution context actually is
- Every context runs in two phases
- What lives inside a context: variables, scope chain, this
- The call stack: how contexts pile up and get cleared
- Why hoisting falls out of the model
- Where the model misleads people
- Read the setup, not just the lines
What an execution context actually is
An execution context is the environment the engine builds around a chunk of code so that code can run: where its variables live, which outer variables it can see, and what this refers to. The ECMAScript specification gives this its own clause — Executable Code and Execution Contexts — and tracks contexts on an execution context stack that behaves last-in, first-out.
Three kinds matter in practice:
- Global context. One per program, created before any of your code runs. In a browser, this is the environment that already holds built-ins like
console,Math, anddocument— the same DOM you build with semantic HTML elements. In a classic script, top-levelthisisglobalThis. - Function context. Created for every call, not once per function. Call a function three times and you get three separate contexts, each with fresh local variables.
- Module context. Load the same file as an ES module and the top-level
thisisundefinedinstead ofglobalThis— the module rules differ from script rules in ways that bite people who test in one mode and ship in the other.
If you’re wondering where this all begins in the browser, it’s the moment the HTML parser hits your script tag and hands the code to the engine — the last stop of the request journey from DNS to first paint.
Every context runs in two phases
Before a context executes its statements, the engine does a setup pass over the code. People call this the creation phase. During it, the engine registers every declaration:
varbindings are created and initialized toundefined- function declarations are fully created, name and body
let,const, andclassbindings are created but left uninitialized- the scope chain and the
thisbinding are determined
Then the execution phase runs the code line by line — assignments happen, functions get called, and an uninitialized let binding finally receives its value at the moment execution reaches its line.
One honesty note: “creation phase” is a teaching model, not the spec’s wording. The spec defines concrete steps such as GlobalDeclarationInstantiation and FunctionDeclarationInstantiation that run before statement evaluation — the two-phase model just describes what those steps look like from outside. The observable behaviour matches.
That single setup pass is the answer to everything in the opening snippets. But to use it well, you need to know what the setup actually produces.
What lives inside a context: variables, scope chain, this
Every execution context carries three things.
The variable environment. The bindings this context owns — its parameters and local variables. Each call gets its own, which is why recursion works at all: three pending calls of the same function never overwrite each other’s locals.
The scope chain. Each context keeps a reference to its outer environment. When code reads an identifier, the engine looks in the current context first, then walks outward link by link until it finds a binding — or exhausts the chain and throws ReferenceError: x is not defined. The chain is decided by where the code is written, not where the function is called from. Hold that thought for the series’ next post; it’s the raw material of closures.
The this binding. Determined by how the code runs:
| Where the code runs | this is |
|---|---|
| Top level of a classic script | globalThis |
| Top level of an ES module | undefined |
A method called as obj.fn() |
obj |
| An arrow function | inherited from where it’s defined |
| A CommonJS module | module.exports |
The rules behind this table come straight from how the spec and MDN describe this. The row people forget is the arrow function: arrow functions don’t create their own this binding at all, so they see whatever this the surrounding context already had.
The call stack: how contexts pile up and get cleared
Contexts need a manager, and that manager is the call stack. The MDN definition is short: when a script calls a function, the function’s context goes on the stack; when that function finishes, its context comes off; execution resumes exactly where the caller left off. JavaScript runs on one thread with one stack, so exactly one context — the top of the stack — is running at any moment.
Watch a two-call program move through it:
function makeTea() {
boilWater();
return "tea";
}
function boilWater() {
return "boiling";
}
makeTea();
| Moment | Stack (top first) |
|---|---|
makeTea() is called |
makeTea → global |
inside it, boilWater() is called |
boilWater → makeTea → global |
boilWater returns |
makeTea → global |
makeTea returns |
global |
Now the part I find genuinely satisfying: this is also where one of JavaScript’s most infamous errors lives. The stack is a fixed chunk of memory, and every context on it takes space. A recursive function with no base case keeps pushing new contexts forever:
function grow(n) {
return grow(n + 1); // no base case
}
grow(0);
When the stack outgrows its assigned space, the engine throws rather than corrupt memory. The message depends on the engine: V8-based browsers and Safari say RangeError: Maximum call stack size exceeded, while Firefox says InternalError: too much recursion. Same failure, two names — worth knowing so you recognise it in either console. The fix is almost always the missing base case, not the stack size.
Why hoisting falls out of the model
With the setup pass in place, “hoisting” stops being a rule to memorise and becomes an observation:
- Reading
flavorbefore its line works because the binding was created during setup and initialized toundefined— the execution phase only assigns the value"mango"when it reaches that line. - Calling
makeCoffee()early works because the whole function existed from the end of setup. - Reading
totalfails because the binding exists but is uninitialized. The engine knows the variable — it just refuses to hand you an uninitialized one. That’s the temporal dead zone.
Nothing moved. MDN’s glossary is blunt about this: “hoisting” isn’t even a normative term in the specification — it’s shorthand for several early-binding behaviours, and the spec’s own HoistableDeclaration group only covers functions.
Here’s the consequence that surprises most people, and my favourite detail in this whole area. typeof has a famous safety property: it never throws on undeclared variables. But on a let variable in its dead zone, it throws:
console.log(typeof ghost); // "undefined" — ghost was never declared
console.log(typeof score); // ReferenceError
let score = 10;
Undeclared means no binding exists anywhere, and typeof special-cases that into the string "undefined". A dead-zone let means the binding exists but is uninitialized, so a real lookup happens and the lookup throws, as MDN’s let page documents. Two situations that look identical at the line of code, completely different under the model.
One smaller consequence of the same setup: top-level var creates a property on the global object, top-level let does not — so globalThis.total stays undefined even after the line above runs.
Try this
- Open your browser console or a
nodeREPL.- Before pressing Enter, predict the output of
console.log(snack); var snack = "chips";and then ofconsole.log(drink); let drink = "tea";.- Run each as its own line.
Expected result: the first logs
undefined; the second throws “Cannot access ‘drink’ before initialization”. Now changelettovarand rerun — both log values with no error.
Where the model misleads people
The two-phase model is strong, but three over-generalisations trip people up.
“Declarations move to the top.” They don’t — that’s a metaphor for early binding creation. The metaphor breaks the moment you add blocks: the dead zone starts at the top of the block, not the top of the function, which is exactly why the shadowed-const puzzles you’ll meet online behave the way they do. When in doubt, drop the metaphor and ask what got created during setup.
“The engine literally runs two passes.” It’s a model. Real engines parse, pre-scan, and JIT-compile in far more sophisticated ways; what’s guaranteed is the observable behaviour, and when you need ground truth, clause 9 of the spec is the authority.
“Pending callbacks sit on the stack.” They don’t. The stack only holds currently running contexts. When a timer or network callback finally fires, the stack is typically empty and a fresh context starts for it. The call stack tells you what’s executing now — it says nothing about what’s queued for later.
Read the setup, not just the lines
The shift worth keeping is this: every “weird” before-declaration behaviour you’ll meet is context setup showing through the lines. undefined from a var — a binding created early. A callable function above its declaration — a function created early. A ReferenceError on a line that looks perfectly fine — a binding that exists but hasn’t been initialized yet.
So the next time one of those errors points at innocent-looking code, don’t reread the line. Ask which context created the binding, and when initialization actually happens. And notice that the scope chain — the outward path a context remembers — has one more trick: it can outlive the context that created it. That trick is closures, and it’s exactly where this series goes next.
Sources
- Executable Code and Execution Contexts — ECMAScript Specification, clause 9
- Hoisting — MDN Web Docs Glossary
- let declaration, including the temporal dead zone — MDN Web Docs
- Call stack — MDN Web Docs Glossary
- InternalError: too much recursion / RangeError: Maximum call stack size exceeded — MDN Web Docs
- The this keyword — MDN Web Docs