Execution context in JavaScript
Execution context is the runtime environment JavaScript creates for every piece of code it runs, bundling together variables, the this binding, and access to the outer scope.
Theory
TL;DR
- Think of it like a worker's desk: each function call gets its own desk (context) with its own notes (variables), knows who the manager is (outer scope), and tracks the current task (
this). - Three types: global (one per script), function (one per call), eval (inside
eval()). - Two phases per context: creation phase allocates memory, execution phase runs code.
varhoists toundefinedin the creation phase;let/conststay uninitialized (Temporal Dead Zone).- Contexts stack on the call stack. Inner contexts access outer ones via scope chain, not the other way around.
Quick example
console.log(x); // undefined - var hoisted in creation phase
var x = 1;
console.log(x); // 1
function test() {
console.log(y); // ReferenceError - let stays in TDZ
let y = 2;
}
test();var x gets allocated and set to undefined before any code runs. let y inside test() exists in memory but is off-limits until that exact line executes. That gap is the Temporal Dead Zone.
What an execution context contains
Every context, global or function, shares the same internal structure:
- LexicalEnvironment stores
let,const, and function declarations, plus a reference to the outer environment (scope chain). - VariableEnvironment stores
vardeclarations and function arguments. - ThisBinding holds whatever
thispoints to in this context.
In the browser, the global context sets this to window. In Node.js, it sets this to globalThis (or {} inside a module).
The two phases
Creation phase runs before a single line of code executes. The engine scans the scope, allocates memory, and:
- sets
vardeclarations toundefined - marks
let/constas uninitialized (TDZ) - stores full function declarations (not expressions) in memory right away
Execution phase then runs code top to bottom. Variables get their actual values, functions get called, and each call pushes a new context onto the call stack.
The call stack
The call stack is a LIFO structure. When a function is called, a new context goes on top. When it returns, that context pops off.
function first() {
second();
}
function second() {
third();
}
function third() {
console.log("third");
}
first();
// Stack at deepest point:
// [Global] -> [first()] -> [second()] -> [third()]V8 (Chrome and Node.js) supports roughly 10,000 frames before throwing RangeError: Maximum call stack size exceeded. Infinite recursion hits that ceiling fast.
Key difference: LexicalEnvironment vs VariableEnvironment
Both exist inside every execution context, but they serve different purposes. let, const, and function declarations go into LexicalEnvironment. var and arguments go into VariableEnvironment. The practical consequence: var initializes to undefined during creation, so reading it before its line gives undefined. let/const do not initialize, so reading them before their line throws.
Closures work because an inner function's LexicalEnvironment keeps a reference to its outer context's environment. That reference stays alive even after the outer function returns.
When to use this knowledge
- Debugging a
this is undefinederror in a callback: recreate the execution context mentally to see whatthiswas at call time. - Explaining why a
varvariable reads asundefinedinstead of throwing: creation phase, see hoisting. - Investigating a stale closure in React: the hook captured variables from an earlier render's context.
- Tracing nested function scope: follow the scope chain up through parent contexts.
How V8 handles this internally
V8 parses code into an Abstract Syntax Tree first. For each execution context, the Ignition interpreter builds LexicalEnvironment and VariableEnvironment records, then resolves this. For async generators, Ignition suspends the context on yield and resumes it with the same environment intact, without unwinding the call stack. That is how await works too: the function context pauses and hands control back to the event loop, which schedules resumption via the microtask queue.
Common mistakes
Assuming let hoists like var:
console.log(name); // ReferenceError, not undefined
let name = 'Alice';let is hoisted in the sense that the engine registers its existence, but it does not initialize until that line runs. Reading it before then throws. I have seen this trip up experienced developers who switch between var and let in legacy codebases.
Losing this in a nested callback:
const obj = {
path: '/users',
handle: function(req, res) {
setTimeout(function() {
console.log(this.path); // undefined - new context, this = global
}, 0);
}
};Arrow functions fix this. They do not create their own this binding and inherit it from the enclosing lexical context.
setTimeout(() => {
console.log(this.path); // '/users'
}, 0);Expecting window as this in strict mode:
'use strict';
function fn() {
console.log(this); // undefined, not window
}
fn();Strict mode sets function context this to undefined when the function is called without an explicit receiver.
Stack overflow from deep recursion:
function factorial(n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
factorial(10000); // RangeError in V8 (~10k frame limit)Each call pushes a new function execution context. Use a loop or a trampoline pattern for deep recursion.
Real-world usage
- React: every component render is a function call. Hooks like
useStateanduseCallbackwork because their closures retain the component function's context from that render cycle. - Express: each route handler runs in its own function context. Arrow functions in middleware preserve
thisfrom the outer scope. - Node.js ESM: each module file runs in its own function-like context, giving private scope without polluting
globalThis. - Redux: reducers access state via closure from the store's context, not via
this.
Follow-up questions
Q: What happens during the creation phase?
A: The engine allocates memory for all declarations in scope. var variables get undefined, full function declarations are stored, and let/const are marked uninitialized. this is bound, and the reference to the outer environment is set.
Q: How does a closure relate to execution context?
A: When an inner function is defined, its LexicalEnvironment stores a reference to the outer function's environment. That reference survives after the outer function returns. That surviving reference is the closure.
Q: What is the Temporal Dead Zone?
A: The period from the start of the creation phase until the line where let or const is initialized. During that window, the variable exists in memory but reading it throws a ReferenceError.
Q: What is the difference between global context in a browser and Node.js?
A: In a browser, this in the global context equals window. In Node.js CommonJS modules, this at the top level is {}, not global. The global object is accessible, but it is not the same as this inside a module.
Q: Walk through how V8 handles an async generator in terms of execution context.
A: Ignition suspends the generator's execution context on each yield. The context stays in memory (not on the call stack) and resumes when the generator is iterated again. For async functions, await does the same: the context pauses, and the microtask queue schedules resumption with the original environment intact.
Examples
Basic: hoisting in the creation phase
function outer() {
console.log(a); // undefined - var hoisted during creation
var a = 1;
function inner() {
console.log(a); // 1 - reads from outer context via scope chain
}
inner();
}
outer();
// Output:
// undefined
// 1outer is called, a new function execution context is created. During the creation phase, a is hoisted to undefined. By execution time, a becomes 1. When inner runs, it has no a of its own, so it walks the scope chain to outer's context and finds a = 1.
Intermediate: stale closure in a React effect
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// Captures 'count' from the context of the render when this effect first ran.
// If count was 0 then, it stays 0 here - stale closure.
console.log(count); // always logs 0 if deps array is []
}, 1000);
return () => clearInterval(interval);
}, []); // empty deps: runs once, captures initial render context
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}The setInterval callback closes over count from the context of the first render. That context is frozen at count = 0. Later renders create new function contexts with updated count, but the interval never sees them. Fix: add count to the dependency array, or use setCount(c => c + 1) to avoid needing count in the closure.
Advanced: this context loss in Express handlers
const express = require('express');
const router = express.Router();
router.get('/users', function(req, res) {
// Regular function: this = router
setTimeout(function() {
// New execution context: this = global (or undefined in strict mode)
console.log(this); // {} or undefined
res.json({ ok: true });
}, 0);
});
// Fix: arrow function inherits this from the enclosing context
router.get('/users-fixed', function(req, res) {
setTimeout(() => {
console.log(this); // router - inherited from route handler's context
res.json({ ok: true });
}, 0);
});The arrow function inside setTimeout does not create its own execution context for this. It takes this from the closest regular function above it, which is the route handler. That is the whole mechanism behind why arrow functions solve this loss in callbacks.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.