A friendly, under-the-hood guide to how JavaScript actually runs your code — with practical scenarios you’ll meet in real projects.

Introduction
Ever debugged a bug that only disappears when you move a line of code? Or got a mysterious undefined
even though “the variable definitely exists”? Nine times out of ten, you’re fighting the rules of execution contexts—the JS engine’s way of deciding what variables exist right now, what this
means, and where to look next.
In this guide, we’ll demystify execution contexts with plain English, clear diagrams-in-words, and real code. You’ll learn how the global and function contexts work, how the scope chain is built, why hoisting and the temporal dead zone (TDZ) exist, how this
is bound (and how arrow functions change the game), what closures really capture, and how modules and async/await fit in.
By the end, you’ll have a solid mental model to debug with confidence and write code that behaves exactly the way you intend.
1) What’s an Execution Context, Exactly?
An execution context is the internal “room” where a chunk of JavaScript runs. It’s a data structure the engine creates to keep track of:
- Environment for variables and functions (what names are defined here).
- Lexical scope chain (where to look next if a name isn’t found).
this
binding (whatthis
points to in this run).- Strict mode flag and some book-keeping details.
There are three kinds:
- Global Execution Context (GEC) — created once when your program starts.
- Function Execution Context (FEC) — created every time you call a function.
- Eval Execution Context — created by
eval(...)
(avoid it).
Quick mental model: Every time you run a function, the engine enters a fresh room with its own table of names, its own
this
, and a door back to its parent room (the scope chain).
2) Creation Phase vs Execution Phase (Two Passes)
Every context is built in two phases:
A) Creation Phase
- Build the Lexical Environment (scopes).
- Hoist declarations:
function
declarations: name + body become available immediately.var
: declared and initialized toundefined
.let
/const
: declared but uninitialized (in the TDZ) until their line runs.- Determine
this
binding.
B) Execution Phase
- Run the code top-to-bottom.
- Assign values, execute statements, evaluate expressions.
Think “blueprint first, build second.” The engine plans the room (creation), then lives in it (execution).
3) The Pieces Inside a Context
Each execution context references two related structures:
- Variable Environment — holds
var
declarations (function-scoped) and function declarations. - Lexical Environment — holds
let
/const
(block-scoped) and also function declarations.
Each environment has an Environment Record (the map of names to values) and a [[OuterEnv]] pointer to its outer lexical environment → that chain is your scope chain.
Important nuance:
- Blocks (
{ ... }
) do not create a new execution context, but do create a new lexical environment forlet
/const
. - Functions create both a new execution context and a new lexical environment.
4) The Global Execution Context (GEC)
When your script starts:
- A GEC is created.
- In browsers, the global object is
window
; in Node.js it’sglobal
(orglobalThis
everywhere). - Your top-level
var
declarations become properties on the global object.let
/const
do not.
var a = 1; // window.a === 1 in browsers
let b = 2; // not a property on window
const c = 3; // not a property on window
console.log(this === window); // true in browser scripts (non-module)
Modules are different: In ES modules, top-level
this
isundefined
and the file is implicitly strict. More on that later.
5) Function Execution Contexts (FECs)
Every function call creates a new FEC with:
- A fresh lexical environment for parameters,
let
/const
, and function declarations inside it. - A fresh variable environment for
var
. - A
this
binding determined by how you called the function.
function greet(name) {
const msg = `Hello, ${name}`;
return msg;
}
greet("Ava"); // New FEC with its own `name`, `msg`
greet("Ben"); // A different FEC (not shared!)
Each call has its own variables. That’s why recursion and concurrent calls work predictably.
6) The Scope Chain (Lexical Scoping)
JavaScript uses lexical (static) scoping: the places where you wrote your functions decide their outer scopes, not where you call them.
const app = "Play";
// Outer scope (global)
function outer() {
const app = "Settings";
function inner() {
console.log(app); // "Settings" from outer's scope
}
inner();
}
outer();
Even if you call inner
elsewhere, it remembers the environment where it was created. That memory is a closure (details soon).
Scope lookup order:
Current env → Parent env → … → Global env. If not found: ReferenceError
.
7) Hoisting & TDZ (in Context)
During the creation phase of a context:
function foo() {}
is fully hoisted—usable anywhere in the same scope.var x
is hoisted and initialized toundefined
.let y
/const z
are hoisted but uninitialized → TDZ until the declaration line runs.
console.log(a); // undefined
var a = 1;
console.log(b); // ReferenceError (TDZ)
let b = 2;
say(); // "hi" (function decl hoisted)
function say() { console.log("hi"); }
Gotcha: Arrow functions and function expressions follow the variable’s hoisting rule, not function-declaration rules.
8) this
Binding Rules (and Arrow Functions)
this
depends on call site, not definition site (except for arrow functions):
- Plain call:
fn()
- Non-strict:
this
is the global object. - Strict mode:
this
isundefined
.
2. Method call: obj.fn()
→ this
is obj
.
3. 3.call/apply/bind
: set this
explicitly.
fn.call(obj, arg1)
fn.apply(obj, [arg1])
const g = fn.bind(obj)
4. Constructor call: new Fn()
→ this
is the new instance.
5. Arrow functions: no own this
; they lexically capture this
from the nearest non-arrow function context.
const person = {
name: "Ava",
hello() {
setTimeout(function () {
console.log(this.name); // undefined or global.name
}, 0);
setTimeout(() => {
console.log(this.name); // "Ava" (lexically captured from `hello`)
}, 0);
},
};
person.hello();
Tip: Use arrow functions for callbacks that need the surrounding
this
. Use normal functions when you need dynamicthis
.
9) Closures: How Contexts Stay Alive
When a function uses variables from its outer scope, it creates a closure: the engine keeps the outer environment alive as long as the inner function can access it.
function counter() {
let n = 0;
return () => ++n;
}
const inc = counter();
console.log(inc()); // 1
console.log(inc()); // 2
n
lives on even after counter
returns, because inc
still references it.
Common pitfall (fixed by let
):
// ❌ with var
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => i);
}
console.log(fns[0](), fns[1](), fns[2]()); // 3 3 3
// ✅ with let
const fns2 = [];
for (let i = 0; i < 3; i++) {
fns2.push(() => i);
}
console.log(fns2[0](), fns2[1](), fns2[2]()); // 0 1 2
Each let
iteration creates a new binding, so each closure captures a distinct value.
10) Blocks Create New Lexical Environments (Not Contexts)
Blocks (if
, for
, {}
) don’t spin up a brand-new execution context, but they do create a new lexical environment for let
/const
.
let x = 1;
{
let x = 2;
console.log(x); // 2
}
console.log(x); // 1
Why it matters: Shadowing is intentional. It keeps variables local and reduces leakage across blocks.
11) Real Project Scenarios You’ll Actually Hit
A) Config Leak in Node (fixed by const
+ strict mindset)
// bad.js
function loadConfig() {
env = "prod"; // ❌ accidental global in sloppy mode
return { env };
}
In sloppy mode this creates a global env
. In strict/module code it throws—good. Always declare:
function loadConfig() {
const env = "prod";
return { env };
}
B) React setState + Closures (stale state)
const [count, setCount] = useState(0);
function handleClick() {
setTimeout(() => {
setCount(count + 1); // might use stale `count`
}, 0);
}
Use the functional form to avoid capturing stale count
:
setTimeout(() => {
setCount(prev => prev + 1);
}, 0);
Here, React gives you the current value, not the one from the closure.
C) this
Surprise in Event Handlers
const btn = document.querySelector("button");
const handler = {
label: "Save",
onClick() {
console.log(this.label); // "Save" if called as method
},
};
btn.addEventListener("click", handler.onClick);
// In browsers, `this` inside onClick is the element, not `handler`
Fix with bind
or arrow wrapper:
btn.addEventListener("click", handler.onClick.bind(handler));
// or
btn.addEventListener("click", (e) => handler.onClick(e));
12) Strict Mode & Modules: Context Defaults in 2025
- ES modules (
type="module"
in<script>
or.mjs
/"type": "module"
in Node) are always strict. - Top-level
this
in modules isundefined
. - Top-level await (in modules) pauses module evaluation, but the module’s context and imports stay intact.
// module.mjs
console.log(this); // undefined
const data = await fetch("/api").then(r => r.json());
export default data;
Takeaway: In modern code, assume strict semantics; sloppy quirks are legacy.
13) Async/Await and Context Hops
async/await
makes async code look sync, but contexts still hop across turns of the event loop. Variable scope is preserved by closures; the call stack is not.
async function sequence() {
const a = 1;
await new Promise(r => setTimeout(r, 0));
// Execution resumes later; `a` is still here (closure)
console.log(a); // 1
}
sequence();
Key idea: Closures persist lexical environments across async boundaries; the engine reconstructs the call stack when resuming.
14) The Call Stack, In Words
Imagine a vertical stack:
[top] inner() ← currently running
outer()
(global)
- Calling a function pushes a new execution context on the stack.
- Returning pops it.
- Errors bubble up the stack (unless caught).
Debug tip: Browser DevTools “Scope” panel mirrors the lexical environments; “Call Stack” shows contexts in order.
15) Step-by-Step: Building Your Mental Model
When something behaves weirdly, walk through these steps:
- Where was this function defined? (decides lexical scope)
- How was it called? (decides
this
) - Which phase are we in? (creation vs execution → hoisting/TDZ)
- Which environment defines this name? (current → outer → global)
- Are we jumping across async boundaries? (closures ≠ call stack)
- Are we in a module/strict context? (assume yes in modern code)
16) Common Gotchas (and Fixes)
- Using
var
in loops with async callbacks → uselet
or IIFE. - Relying on top-level
this
→ modules set it toundefined
; avoid. - Forgetting
new
→ calling constructor withoutnew
changesthis
. - Shadowing by accident → prefer smaller scopes and explicit names.
- Leaking references in closures → unsubscribe listeners, null out large closed-over objects when done.
17) Practical Patterns That Leverage Contexts
A) Module Pattern (IIFE)
const Store = (function () {
const state = {}; // private
return {
get(k) { return state[k]; },
set(k, v) { state[k] = v; },
};
})();
Uses closure to encapsulate state.
B) Factory with Private Members
function createCounter() {
let n = 0;
return {
inc() { n++; },
val() { return n; }
};
}
Private n
survives across calls.
C) Partial Application via bind
function greet(prefix, name) {
return `${prefix}, ${name}!`;
}
const hello = greet.bind(null, "Hello");
console.log(hello("Ava")); // "Hello, Ava!"
18) Execution Contexts & Performance Notes
- Stable object shapes help engines optimize; avoid adding/removing properties dynamically on hot objects.
- Arrow functions reduce accidental dynamic
this
rebinding in tight loops and callbacks. - Avoid deep scope chains in hot paths when possible; lookups walk the chain.
Not micro-optimization territory for most apps, but useful in libraries and performance-sensitive code.
19) Mini Reference Table

20) Worked Examples (Copy-Paste Ready)
Example 1: Fix a Hoisting/TDZ Crash
console.log(total()); // ❌ TypeError: total is not a function
const total = function () { return 42; };
// ✅ Use a function declaration if you need it early:
function sum() { return 42; }
console.log(sum()); // 42
Why: function expressions follow variable hoisting (TDZ with const
), while declarations are fully hoisted.
Example 2: Safe this
in Class Callbacks
class Timer {
constructor() {
this.ticks = 0;
}
start() {
setInterval(() => { // arrow keeps lexical `this`
this.ticks++;
}, 1000);
}
}
Example 3: Block Scopes for Cleanup
{
const temp = heavyComputation();
use(temp);
} // temp eligible for GC sooner
Example 4: Stable Closures Across Async Boundaries
function loadWithCache(fetcher) {
let cache = null;
return async function () {
if (cache) return cache; // closure over `cache`
cache = await fetcher();
return cache;
};
}
Conclusion
Execution contexts aren’t magic — they’re a reliable system the engine uses to decide what names exist, what this
means, and where to look next. Once you see how creation vs execution, lexical environments, hoisting/TDZ, this
rules, and closures fit together, the “weird” parts of JavaScript become predictable.
Key takeaways:
- Functions create new execution contexts; blocks create new lexical environments.
function
declarations are fully hoisted;let
/const
live in the TDZ until initialized.this
depends on how you call a function; arrow functions capture the surroundingthis
.- Closures preserve variables across time and async boundaries — use them intentionally.
- Modules are strict and set top-level
this
toundefined
—assume modern semantics.
Pro tip: When debugging, narrate the engine’s steps: which context am I in, what’s the current environment, what’s the outer environment, and how was this function called? You’ll spot the issue faster.
Call to Action
What’s the trickiest context/this
/closure bug you’ve fought recently?
💬 Share the story in the comments.
🔖 Bookmark this for the next time “moving a line” mysteriously fixes a bug.
👩💻 Share with a teammate who’s learning JS internals or switching to TypeScript/React.
Leave a Reply