Understanding Execution Context in JavaScript: Scopes, Hoisting, and the 'this' Keyword
Understanding Execution Context in JavaScript: Scopes, Hoisting, and the 'this' Keyword
I was debugging a production bug where this was undefined, and I realized I
didn't truly understand execution context. I had used JavaScript for years, but
when it came to explaining why variables are accessible in certain places,
why this changes based on how a function is called, or how JavaScript
actually executes code, I couldn't give a clear answer.
// My bug - this was undefined
const user = {
name: 'John',
getName: function () {
return this.name;
},
};
const getNameFn = user.getName;
console.log(getNameFn()); // undefined? What?
That's when I decided to dive deep into execution context—the fundamental
concept that explains how JavaScript actually runs your code. Understanding
execution context isn't just academic knowledge; it's the foundation that
explains scope, hoisting, closures, and the this keyword.
In this post, we're going to explore execution context from the ground up. But here's what makes this different: I'm learning alongside you. As I researched and wrote this, I went through my own learning journey—asking questions, discovering connections, and building mental models.
We'll understand:
- How JavaScript creates execution contexts (the two-phase process)
- How variable scoping actually works (lexical scoping and the scope chain)
- Why hoisting happens (it's not magic—it's the creation phase!)
- How
thisgets its value (the four binding rules) - How closures relate to execution contexts (they preserve contexts!)
Intended audience: JavaScript developers who want to understand the language
deeply—from developers who've encountered confusing scope or this behavior, to
intermediate developers who want to understand the "why" behind JavaScript's
execution model and write more predictable code.
Note: This post follows a learning-first approach. As you read, you'll see my own questions, discoveries, and "aha!" moments. The goal is for both of us to understand execution context deeply, not just memorize facts.
Table of Contents
- What is Execution Context?
- The Two Types of Execution Context
- How Execution Context is Created
- Understanding Scope and the Scope Chain
- Variable Hoisting: Why It Works This Way
- Function Hoisting vs Variable Hoisting
- The 'this' Keyword: The Confusing Part
- Why 'this' Changes Based on How You Call Functions
- Common Mistakes I Made
- Arrow Functions and 'this': What's Different?
- Closures and Execution Context
- Why You Should Be Careful
- Best Practices
- Key Takeaways
What is Execution Context?
When I started researching this topic, I discovered something important: execution context is an abstract concept, not a physical object in memory. It's JavaScript's way of tracking the environment where code is evaluated and executed.
An execution context holds three essential components:
- Lexical Environment (or Variable Object in older terminology): Stores
variables, function declarations, and the
argumentsobject - Scope Chain: Links to outer lexical environments, enabling variable lookup
thisBinding: Determines whatthisrefers to in the current context
Here's what clicked for me: Every time JavaScript runs code, it creates an
execution context. This context isn't just a container—it's the active
environment that determines what variables you can access, what this refers
to, and how your code interacts with other code.
The Mental Model That Helped Me Understand
I struggled with the abstract nature of execution contexts until I started thinking of them as workspaces. Imagine you're working on a project:
function buildWebsite() {
const tools = ['VS Code', 'Terminal', 'Browser'];
function debugCode() {
const debugger = 'Chrome DevTools';
// Can access tools from outer workspace
console.log(tools); // Works!
}
debugCode();
}
In this analogy:
- Each function gets its own workspace (execution context)
- The inner workspace can see what's in the outer workspace (scope chain)
- But the outer workspace can't see what's in the inner workspace (scope isolation)
Why Would They Design It This Way?
As I researched JavaScript's history, I learned that execution contexts solve several critical problems:
Historical Context: JavaScript was designed in 1995 for browsers with DOM manipulation. It needed:
- Variable Isolation: Without separate contexts, all variables would be global, leading to naming conflicts and catastrophic bugs
- Memory Management: When a function finishes, its context can be garbage collected (unless preserved by closures)
- Predictable Scope: Each function has a clear boundary of what variables it can access—crucial for avoiding bugs
- Support for Closures: Functions needed a way to "remember" their environment, which execution contexts enable
- Dynamic 'this': Allows functions to work with different objects based on how they're called—a powerful design pattern
The design choice of lexical scoping (scope determined by where code is written, not where it's called) makes closures possible and code more predictable. This was a deliberate decision, not an accident.
The Two Types of Execution Context
JavaScript has two types of execution contexts:
1. Global Execution Context
Created when JavaScript first starts running. There's only one global context per program.
// Global execution context
const globalVar = "I'm in the global context";
function myFunction() {
// Function execution context (created when called)
console.log(globalVar); // Can access global context
}
The global context:
- Has a global object (
windowin browsers,globalin Node.js) thisrefers to the global object- Contains all code not inside a function
2. Function Execution Context
Created every time a function is called (not when it's defined).
function sayHello() {
// Function execution context created here when called
const message = 'Hello';
return message;
}
sayHello(); // Context created
sayHello(); // New context created (different from first call)
Each function call creates a new execution context, even if it's the same function. This is why closures work—each call gets its own isolated environment.
How Execution Context is Created
This was the "aha!" moment for me. Understanding how execution context is created revealed why hoisting happens—something that had confused me for years. The creation happens in two distinct phases:
Phase 1: Creation Phase (Before Code Executes)
During the creation phase, JavaScript essentially prepares the workspace before running any code:
-
Creates the Lexical Environment (or Variable Object in ES5 terminology):
- Scans for variable declarations with
var(hoists them, initializes toundefined) - Scans for function declarations (hoists them completely—both declaration and definition)
- Creates the
argumentsobject (for function contexts)
Technical Note: In ES5, this was called the "Variable Object" (VO). In ES6+, it's called the "Lexical Environment" which includes an outer reference that forms the scope chain. The terminology changed, but the concept is similar—it's the structure that stores identifiers and their bindings.
- Scans for variable declarations with
-
Establishes the Scope Chain:
- Links to outer lexical environment via outer reference
- Creates the path JavaScript will follow to find variables
- This chain is static—determined by where code is written, not where it's called
-
Determines
thisBinding:- Based on how the function will be called (though final value determined at call time)
Here's what happens with variables—and this is where hoisting clicks:
console.log(myVar); // undefined (not ReferenceError!)
var myVar = 'Hello';
// What JavaScript actually does:
// 1. Creation phase: var myVar = undefined (hoisted)
// 2. Execution phase: myVar = "Hello"
During creation, var declarations are hoisted and initialized to undefined.
This is why you can access them before the declaration line.
Phase 2: Execution Phase
During the execution phase, JavaScript:
- Assigns values to variables
- Executes code line by line
- Makes function calls (which create new contexts)
function example() {
console.log(a); // undefined (creation phase)
var a = 10; // Execution phase: assignment
console.log(a); // 10 (execution phase)
}
Why Would They Design It This Way?
After researching this, I understood the design rationale. The two-phase approach allows JavaScript to:
- Support function hoisting: You can call functions before they're declared—a common and useful pattern
- Catch syntax errors early: JavaScript knows about all declarations before execution begins
- Enable closures: Functions know about their lexical environment before executing, which is essential for closures to work
- Performance optimization: Having all declarations known upfront allows for better optimization
The "why" behind this design made the "how" much clearer to me. It's not JavaScript being quirky—it's a deliberate design that enables powerful patterns.
Understanding Scope and the Scope Chain
Here's an important distinction I learned: Scope and Execution Context are related but different:
- Scope: Where variables are accessible (static, determined by code structure)
- Execution Context: The runtime environment when code executes (dynamic, created per function call)
- Scope Chain: The path JavaScript follows to find a variable, formed by linking lexical environments
Lexical Scoping: The Foundation
JavaScript uses lexical scoping (also called static scoping). This crucial concept means scope is determined by where code is written, not where it's called.
This distinction matters because it enables closures. Here's what I mean:
const globalVar = 'global';
function outer() {
const outerVar = 'outer';
function inner() {
const innerVar = 'inner';
console.log(innerVar); // "inner" - found in current scope
console.log(outerVar); // "outer" - found in outer scope
console.log(globalVar); // "global" - found in global scope
}
inner();
}
outer();
When inner() tries to access outerVar, JavaScript:
- Checks
inner()'s scope first - If not found, checks
outer()'s scope - If not found, checks global scope
- If still not found, throws ReferenceError
This chain is established during the creation phase based on where functions are written—this is why it's called "lexical" (relating to the words/code structure, not runtime behavior).
Key Insight: The scope chain is formed by the outer reference in each lexical environment. When JavaScript looks for a variable, it follows this chain from inner to outer until it finds the variable or reaches the global scope.
Scope Chain in Action
const level0 = 'global';
function level1() {
const level1Var = 'level 1';
function level2() {
const level2Var = 'level 2';
function level3() {
const level3Var = 'level 3';
// Can access all outer scopes
console.log(level3Var, level2Var, level1Var, level0);
}
level3();
}
level2();
}
level1();
The scope chain for level3() is: level3 → level2 → level1 → global
Variable Hoisting: Why It Works This Way
Hoisting is JavaScript's behavior of moving declarations to the top of their scope during the creation phase.
var Hoisting
console.log(x); // undefined (not ReferenceError!)
var x = 5;
console.log(x); // 5
What actually happens:
// Creation phase
var x = undefined; // Hoisted
// Execution phase
console.log(x); // undefined
x = 5;
console.log(x); // 5
let and const: Temporal Dead Zone
let and const are hoisted differently—they're in the "Temporal Dead Zone"
until the declaration line.
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
What happens:
// Creation phase
// x is hoisted but uninitialized (Temporal Dead Zone)
// Execution phase
console.log(x); // Error: can't access before initialization
let x = 5; // Now initialized
Why the Difference? (What I Discovered)
This was a key insight during my research: The Temporal Dead Zone for
let/const prevents bugs. But why did JavaScript designers add this
complexity?
The answer: Better error detection. With var, you could silently access
undefined variables, leading to subtle bugs. With let/const, JavaScript
forces you to write code in a way that's easier to reason about:
The Temporal Dead Zone prevents bugs:
// With var - buggy code works
console.log(name); // undefined (silent bug!)
var name = 'John';
// With let - catches the bug early
console.log(name); // ReferenceError (caught immediately!)
let name = 'John';
Function Hoisting vs Variable Hoisting
Functions are hoisted differently depending on how they're declared.
Function Declarations: Fully Hoisted
sayHello(); // Works! "Hello"
function sayHello() {
console.log('Hello');
}
Function declarations are hoisted completely—both the declaration and definition are available.
Function Expressions: Partially Hoisted
sayHello(); // TypeError: sayHello is not a function
var sayHello = function () {
console.log('Hello');
};
What happens:
// Creation phase
var sayHello = undefined; // Variable hoisted
// Execution phase
sayHello(); // TypeError: undefined is not a function
sayHello = function() { ... };
Arrow Functions: Same as Function Expressions
sayHello(); // TypeError: sayHello is not a function
const sayHello = () => {
console.log('Hello');
};
The 'this' Keyword: The Confusing Part
this was the topic that made me realize I didn't truly understand execution
context. It's one of the most confusing aspects of JavaScript, and for good
reason—its behavior seems inconsistent until you understand the underlying
rules.
Here's the fundamental insight I discovered: this is determined by how a
function is called, not where it's defined. This is different from scope,
which is determined lexically (where code is written).
Let me share what I learned about the four binding rules:
The Four Rules for 'this'
1. Global Context
In the global scope, this refers to the global object.
console.log(this); // Window (browser) or global (Node.js)
function regularFunction() {
console.log(this); // Also Window/global (unless in strict mode)
}
regularFunction();
2. Method Call (Object Method)
When a function is called as a method of an object, this refers to that
object.
const user = {
name: 'John',
getName: function () {
return this.name; // this = user
},
};
console.log(user.getName()); // "John"
3. Function Call (Not as Method)
When a function is called normally (not as a method), this is undefined
(strict mode) or the global object (non-strict mode).
'use strict';
const user = {
name: 'John',
getName: function () {
return this.name;
},
};
const getNameFn = user.getName;
console.log(getNameFn()); // TypeError: Cannot read property 'name' of undefined
This is the bug I encountered! When you extract a method and call it separately,
this loses its binding.
4. Constructor Call (with 'new')
When a function is called with new, this refers to the newly created object.
function User(name) {
this.name = name; // this = new object being created
}
const user = new User('John');
console.log(user.name); // "John"
Why 'this' Changes Based on How You Call Functions
This is the key insight: this is not bound when you define a function, but
when you call it.
const obj1 = {
name: 'Object 1',
getName: function () {
return this.name;
},
};
const obj2 = {
name: 'Object 2',
};
// Same function, different 'this'
console.log(obj1.getName()); // "Object 1" (this = obj1)
obj2.getName = obj1.getName;
console.log(obj2.getName()); // "Object 2" (this = obj2)
// Or call it with no context
const getNameFn = obj1.getName;
console.log(getNameFn()); // undefined or global (this = global/undefined)
Explicit Binding: call, apply, bind
You can explicitly set this using call(), apply(), or bind():
function introduce(greeting, punctuation) {
return `${greeting}, I'm ${this.name}${punctuation}`;
}
const person = { name: 'John' };
// call - pass arguments individually
console.log(introduce.call(person, 'Hello', '!'));
// "Hello, I'm John!"
// apply - pass arguments as array
console.log(introduce.apply(person, ['Hi', '.']));
// "Hi, I'm John."
// bind - create new function with bound 'this'
const boundIntroduce = introduce.bind(person);
console.log(boundIntroduce('Hey', '?'));
// "Hey, I'm John?"
Common Mistakes I Made with Execution Context
Mistake 1: Losing 'this' When Extracting Methods
const button = {
text: 'Click me',
click: function () {
console.log(this.text); // Works here
},
};
// Bug: loses 'this'
const clickHandler = button.click;
clickHandler(); // undefined (this is global/undefined)
Fix: Use bind() or arrow functions:
// Option 1: Bind
const clickHandler = button.click.bind(button);
// Option 2: Arrow function (preserves 'this' from outer scope)
const clickHandler = () => button.click();
Mistake 2: Assuming 'this' in Callbacks
const user = {
name: 'John',
hobbies: ['coding', 'reading'],
printHobbies: function () {
this.hobbies.forEach(function (hobby) {
console.log(this.name + ' loves ' + hobby); // this is undefined!
});
},
};
Fix: Use arrow function or bind:
// Option 1: Arrow function
printHobbies: function() {
this.hobbies.forEach((hobby) => {
console.log(this.name + " loves " + hobby); // this = user
});
}
// Option 2: Bind
printHobbies: function() {
this.hobbies.forEach(function(hobby) {
console.log(this.name + " loves " + hobby);
}.bind(this));
}
Mistake 3: Confusing Scope and Execution Context
Scope is about where variables are accessible. Execution context is about the environment when code runs. They're related but different:
const x = 'global';
function outer() {
const x = 'outer';
function inner() {
console.log(x); // "outer" - from scope chain
console.log(this.x); // "global" - from execution context (this)
}
inner(); // this = global, but x comes from scope
}
Arrow Functions and 'this': What's Different?
Arrow functions don't have their own this. They inherit this from the
enclosing lexical scope.
const obj = {
name: 'John',
regularFunction: function () {
console.log(this.name); // "John" (this = obj)
const arrowFunction = () => {
console.log(this.name); // "John" (inherited from outer scope)
};
arrowFunction();
},
};
obj.regularFunction();
When to Use Arrow Functions
Use arrow functions when you want to preserve this:
class Timer {
constructor() {
this.seconds = 0;
}
start() {
// Arrow function preserves 'this'
setInterval(() => {
this.seconds++;
console.log(this.seconds);
}, 1000);
}
}
Don't use arrow functions when you need dynamic this:
const button = {
text: 'Click me',
// Arrow function - 'this' won't work
click: () => {
console.log(this.text); // undefined (this = global)
},
// Regular function - 'this' works
clickProper: function () {
console.log(this.text); // "Click me" (this = button)
},
};
Closures and Execution Context: How They Connect
Understanding execution context finally made closures click for me. Here's the connection: Closures preserve execution contexts.
A closure is created when an inner function has access to variables from an outer (enclosed) execution context, even after the outer function has finished executing. This works because the inner function maintains a reference to the outer function's lexical environment.
function outer() {
const outerVar = "I'm from outer";
// Inner function forms a closure
function inner() {
console.log(outerVar); // Accesses outerVar from outer context
}
return inner;
}
const innerFn = outer();
innerFn(); // "I'm from outer" - closure preserved the context
Even though outer() finished executing, its execution context is preserved
because inner() still references variables from it.
Practical Closure Example
function createCounter() {
let count = 0; // Private variable
return {
increment: function () {
count++; // Closure preserves count
return count;
},
getCount: function () {
return count; // Closure preserves count
},
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2
Each call to createCounter() creates a new execution context with its own
count variable.
Why You Should Be Careful: Common Pitfalls
Pitfall 1: var in Loops
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i); // Prints 3, 3, 3 (not 0, 1, 2)!
}, 100);
}
Why: All timeouts share the same i variable (function scope, not block
scope).
Fix: Use let (block scope) or create a new context:
// Option 1: let (creates new context each iteration)
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i); // 0, 1, 2
}, 100);
}
// Option 2: IIFE (creates new execution context)
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(function () {
console.log(j); // 0, 1, 2
}, 100);
})(i);
}
Pitfall 2: 'this' in Event Handlers
const button = document.querySelector('button');
const obj = {
name: 'John',
handleClick: function () {
console.log(this.name); // Might not be what you expect!
},
};
button.addEventListener('click', obj.handleClick);
// When clicked: this = button (the element), not obj!
Fix: Use bind() or arrow function:
// Option 1: Bind
button.addEventListener('click', obj.handleClick.bind(obj));
// Option 2: Arrow function
button.addEventListener('click', () => obj.handleClick());
Pitfall 3: Forgetting About Hoisting
var x = 1;
function example() {
console.log(x); // undefined (not 1!)
var x = 2;
}
example();
Why: The local x is hoisted, shadowing the global x before it's
initialized.
Best Practices: Writing Predictable Code
1. Use 'let' and 'const' Instead of 'var'
// ❌ var - function scoped, hoisted, can be redeclared
var name = 'John';
var name = 'Jane'; // No error!
// ✅ let/const - block scoped, clearer behavior
let name = 'John';
const age = 30;
2. Be Explicit with 'this'
// ❌ Ambiguous
button.onclick = obj.method;
// ✅ Explicit binding
button.onclick = obj.method.bind(obj);
3. Use Arrow Functions for Callbacks When Appropriate
// ✅ When you want to preserve 'this'
class Component {
handleClick = () => {
console.log(this); // Always refers to Component instance
};
}
// ❌ When you need dynamic 'this'
const obj = {
name: 'John',
getName: () => this.name, // Won't work as expected
};
4. Understand Your Scope Chain
// ✅ Clear scope boundaries
function processData(data) {
const processed = data.map((item) => {
// Clear: item is parameter, processed is outer scope
return item * 2;
});
return processed;
}
Key Takeaways
-
Execution context is the environment where JavaScript code runs. It includes variables, scope chain, and
this. -
Two phases: Creation (hoisting) and execution (running code). Understanding this explains why hoisting happens.
-
Scope is determined lexically (where code is written), not dynamically (where it's called). This enables closures.
-
thisis dynamic: It's determined by how a function is called, not where it's defined. Usebind(),call(),apply(), or arrow functions to control it. -
Closures preserve execution contexts. Inner functions can access outer variables even after the outer function returns.
-
Use
let/constinstead ofvarfor clearer scoping behavior and to avoid Temporal Dead Zone issues. -
Arrow functions don't have their own
this—they inherit from the enclosing scope. Use them when you want to preservethis, but avoid them for methods that need dynamicthis.
Understanding execution context transformed how I write JavaScript. It's no longer magic—it's a predictable system that, once understood, makes the language's behavior clear and debuggable.
The next time you encounter a scope issue or confusing this behavior, think
about the execution context. Ask yourself: "Where is this code written? How is
this function being called? What context was active when this closure was
created?"
These questions will guide you to the answer.