JavaScript code does not simply run from top to bottom without an internal process. Before executing statements, the JavaScript engine creates execution contexts that determine how variables, functions, scope, and the value of this are handled.
Understanding execution contexts makes concepts like hoisting, lexical scope, closures, and the call stack much easier to reason about. Once you understand what happens when global code, functions, and nested functions execute, many seemingly confusing JavaScript behaviors become predictable.
How JavaScript Execution Context Works
An execution context is the environment in which JavaScript code is evaluated and executed. The main types are the Global Execution Context, Function Execution Context, and specialized contexts created by features such as modules. Each context contains information needed by the engine to resolve variables, determine scope, and execute statements correctly.
When a JavaScript program starts, the engine creates the Global Execution Context and performs setup before executing the code. During this phase, declarations are registered, which explains why function declarations can often be called before their definitions and why var behaves differently from let and const. After the setup phase, the execution phase evaluates statements and assigns values.
Whenever a function is called, JavaScript creates a new Function Execution Context for that invocation. The new context has its own local variables, parameters, scope information, and access to outer lexical environments. The call stack keeps track of these active contexts, pushing a new context when a function starts and removing it when the function returns.
The example below demonstrates the lifecycle of global code and nested function calls. The console messages intentionally expose the order of execution so you can connect execution contexts with hoisting, lexical scope, closures, and the call stack.
console.log("1. Global execution context: start");
// During creation, function declarations are available before execution reaches them.
console.log("2. Calling a function before its declaration:");
showExecutionContext();
// var is initialized with undefined during the creation phase.
console.log("3. var before assignment:", globalVar);
var globalVar = "Global value";
console.log("4. var after assignment:", globalVar);
// let and const are hoisted but remain in the Temporal Dead Zone
// until execution reaches their declarations.
let globalLet = "Global let value";
const globalConst = "Global const value";
console.log("5. Global let:", globalLet);
console.log("6. Global const:", globalConst);
function showExecutionContext() {
console.log("7. Function execution context: entered showExecutionContext()");
// Parameters and local declarations belong to this function context.
const functionName = "showExecutionContext";
let counter = 10;
console.log("8. Local functionName:", functionName);
console.log("9. Local counter:", counter);
// This function creates another execution context when called.
calculateTotal(counter);
console.log("14. Returning from showExecutionContext()");
}
function calculateTotal(value) {
console.log("10. Function execution context: entered calculateTotal()");
// 'value' is a parameter stored in this function's execution context.
const multiplier = 5;
const total = value * multiplier;
console.log("11. Parameter value:", value);
console.log("12. Local multiplier:", multiplier);
console.log("13. Calculated total:", total);
// When this function returns, its execution context is removed
// from the call stack after its work is complete.
return total;
}
console.log("15. Global code continues after the function returns");
function createCounter() {
// This variable belongs to createCounter's lexical environment.
let count = 0;
// The returned function forms a closure over 'count'.
return function increment() {
count += 1;
console.log("16. Closure counter:", count);
return count;
};
}
console.log("17. Creating a closure");
const counter = createCounter();
console.log("18. Calling the closure for the first time");
counter();
console.log("19. Calling the closure for the second time");
counter();
console.log("20. Calling the closure for the third time");
counter();
console.log("21. Program execution completed");
Conclusion
Execution contexts provide the foundation for understanding how JavaScript executes code. The global context starts the program, function calls create new contexts, and the call stack tracks which context is currently active.
Hoisting, lexical scope, parameters, local variables, and closures are all easier to understand when viewed through execution contexts. Instead of memorizing isolated JavaScript rules, think about which execution context owns a variable and which outer lexical environments it can access.
With this mental model, debugging nested functions and asynchronous JavaScript becomes much easier. The next time JavaScript behavior seems surprising, trace the execution context and call stack first; they often reveal exactly what the engine is doing.
Top comments (0)