Module 60 : Execution Context & Call Stack.
Prerequisites: Basic JavaScript syntax, functions, variables, console.log, and asynchronous primitives (setTimeout, Promises).
1. Big picture: what is an execution context?
An Execution Context (EC) is the environment in which JavaScript code is evaluated and executed. Think of it as a container that holds everything the engine needs to run a piece of code: the variables/local values, the scope chain (where to look up variables), the this binding, and links to outer lexical environments.
Every time code starts executing, an execution context is created and a stack frame representing it is pushed onto the call stack. When execution finishes, that frame is popped.
2. Types of execution contexts
Global Execution Context (GEC) — top-level; created when the script first runs. It creates the global object (e.g., window in browsers) and the global scope.
Function Execution Context — created whenever a function is invoked. Each call creates its own EC (even for the same function called multiple times).
Eval Execution Context — created by eval (rarely used and discouraged).
Module Execution Context — modules (import / export) have their own module EC with slightly different rules (they are strict mode by default).
3. Components of an execution context
Each EC contains:
Lexical Environment (or Environment Record): holds variable bindings for that context.
Variable Environment: similar to lexical environment but slightly different historically — for our purposes, the current environment record stores var, function declarations, and (for the lexical) let/const bindings once they are initialized.
This binding: value of this inside that context.
Outer reference: reference to the parent/outer lexical environment (used for scope chain lookup).
4. Two phases of execution (Creation & Execution)
When an execution context is created, the JavaScript engine (internally) does two conceptual phases:
Creation phase (sometimes called the hoisting phase):
Creates the Variable Object / Environment Record for the context.
Places function declarations into memory (the entire function object).
For var declarations: allocates the binding and sets it to undefined.
For let and const: creates the binding but leaves it in the Temporal Dead Zone (TDZ) until execution reaches its declaration.
Sets up the this binding for the context.
Execution phase:
The code runs line-by-line. Assignments initialize let/const bindings (leaving TDZ) and update vars.
Function calls create new function ECs which undergo the same two-phase lifecycle.
Important mental model: function declarations are hoisted (available during creation). var is hoisted but initialized to undefined. let and const exist but are not initialized until their line runs (TDZ).
5. Scope chain & closures
The scope chain is how the engine looks up identifiers: start in the current lexical environment, then follow the outer reference to the parent, and so on until the global environment.
A closure happens when an inner function references variables from its outer lexical environment. The inner function retains a reference to that environment even after the outer function has finished executing.
6. The Call Stack (stack frames)
The call stack (or execution stack) is a LIFO structure where each active execution context is represented as a stack frame.
When the program starts, the global context is pushed. Every function call pushes a new frame. When a function returns, its frame is popped.
Why it matters: Stack frames keep local variables and control flow. recursion or runaway calls can exhaust the stack (StackOverflow).
7. Event Loop, and the Call Stack
The call stack is synchronous: JS runs one stack frame at a time. The Event Loop coordinates asynchronous events by moving callbacks from queues to the call stack when it's empty.
Macrotasks (task queue): setTimeout, setInterval, I/O, UI callbacks.
Microtasks (microtask queue): Promise handlers (.then) and queueMicrotask.
Order: after a stack empties, microtasks are processed to completion before the next macrotask.
Example important behavior:
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
// Output: start, end, promise, timeout
8. (with call-stack snapshots)
Example A — Simple nested calls
function a(){
console.log('a start');
b();
console.log('a end');
}
function b(){
console.log('b');
}
console.log('global start');
a();
console.log('global end');
Call stack timeline (conceptual):
Program starts — push Global EC.
console.log('global start') runs inside Global EC.
Call a() — push a() EC onto stack.
Inside a, console.log('a start') runs, then b() called — push b() EC.
b() logs 'b', returns — pop b() EC.
Continue a — log 'a end', return — pop a() EC.
console.log('global end'), finish — pop Global EC when program ends.
Example B — Hoisting and TDZ
console.log(foo); // ?
var foo = 'bar';
console.log(baz); // ?
let baz = 'qux';
sayHi(); // ?
function sayHi(){ console.log('hi'); }
sayBye(); // ?
var sayBye = function(){ console.log('bye'); }
Explain outputs:
console.log(foo) prints undefined because var foo is hoisted and initialized to undefined during the creation phase.
console.log(baz) throws ReferenceError: Cannot access 'baz' before initialization because let is in TDZ.
sayHi() works because function declarations are hoisted and available as full function objects during creation.
sayBye() throws TypeError because sayBye (declared with var) is undefined at the moment and you cannot call undefined as function.
Example C — Recursion (factorial) and stack frames
function fact(n){
if(n <= 1) return 1;
return n * fact(n - 1);
}
console.log(fact(4));
Call stack frames for fact(4):
fact(4) -> pushes frame for fact(4)
fact(3) -> pushes
fact(2) -> pushes
fact(1) -> pushes -> returns 1 -> pop fact(1)
fact(2) resumes, multiplies -> pop
fact(3) resumes -> pop
fact(4) resumes -> pop
Important: Each call holds its own n and return address. Deep recursion may cause RangeError: Maximum call stack size exceeded.
Example D — Closures and lifetime
function makeCounter(){
let count = 0;
return function(){
count += 1;
return count;
}
}
const c = makeCounter();
console.log(c()); // 1
console.log(c()); // 2
Why count persists: The returned function's closure keeps a reference to the lexical environment that contains count. Even though makeCounter() returned and its frame was popped, the lexical environment remains alive because c keeps a reference.
Example E — Event loop & microtask vs macrotask
console.log('start');
setTimeout(()=> console.log('timeout'), 0);
Promise.resolve().then(()=> console.log('promise'));
console.log('end');
Output: start, end, promise, timeout. The promise .then handler is a microtask processed before the macrotask (the setTimeout callback).
9. exercises
Exercise 1 — Trace the output & stack
console.log('1');
function foo(){
console.log('2');
bar();
console.log('5');
}
function bar(){
console.log('3');
baz();
}
function baz(){
console.log('4');
}
foo();
console.log('6');
Task: Write the order of console logs and show a short call stack trace at the point where baz() executes.
Solution: Output order: 1,2,3,4,5,6. Call stack at baz() execution: Global -> foo -> bar -> baz.
Exercise 2 — Hoisting trickiness
console.log(typeof funcA); // ?
console.log(typeof funcB); // ?
function funcA() {}
var funcB = function() {}
Solution:
typeof funcA -> 'function' (function declaration hoisted)
typeof funcB -> 'undefined' (var hoisted to undefined, typeof returns 'undefined')
Exercise 3 — TDZ failure
{
console.log(a);
let a = 5;
}
What's the result? Explain.
Solution: Throws ReferenceError because a is in TDZ until it's initialized.
Exercise 4 — Closure & memory (design)
Create a function createLogger() that returns a function which logs a message with an incrementing counter, but make sure it doesn't leak memory by accumulating unbounded data. Provide a safe implementation and explain why it doesn't leak.
Solution (one approach):
function createLogger(){
let count = 0;
return function(msg){
count += 1;
if(count > 1e6) count = 0; // prevents unbounded growth in certain scenarios
console.log(`#${count}:`, msg);
}
}
Why this is safe: The closure only keeps a single primitive (count). There's no accumulation of arrays/objects that would grow without bound.
Exercise 5 — Implement a simple call-stack visualizer (mini project)
Write a helper to trace function entry and exit so you can see call stack growth. Example: traceWrap(fn) should return a wrapped function that logs when it's entered and exited, with a depth-based indent.
Solution:
const _callStack = [];
function traceWrap(fn){
return function traced(...args){
_callStack.push(fn.name || '<anonymous>');
console.log('>'.repeat(_callStack.length), 'enter', fn.name || '<anonymous>');
try{
return fn.apply(this, args);
} finally {
console.log('
No comments:
Post a Comment