javascript Coursejavascriptexecution-contextscopehoistingthis-keywordclosuretutorial

Understanding Execution Context in JavaScript: Scopes, Hoisting, and the 'this' Keyword

19 min read

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 this gets 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?

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:

  1. Lexical Environment (or Variable Object in older terminology): Stores variables, function declarations, and the arguments object
  2. Scope Chain: Links to outer lexical environments, enabling variable lookup
  3. this Binding: Determines what this refers 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 (window in browsers, global in Node.js)
  • this refers 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:

  1. Creates the Lexical Environment (or Variable Object in ES5 terminology):

    • Scans for variable declarations with var (hoists them, initializes to undefined)
    • Scans for function declarations (hoists them completely—both declaration and definition)
    • Creates the arguments object (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.

  2. 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
  3. Determines this Binding:

    • 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:

  1. Assigns values to variables
  2. Executes code line by line
  3. 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:

  1. Checks inner()'s scope first
  2. If not found, checks outer()'s scope
  3. If not found, checks global scope
  4. 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

  1. Execution context is the environment where JavaScript code runs. It includes variables, scope chain, and this.

  2. Two phases: Creation (hoisting) and execution (running code). Understanding this explains why hoisting happens.

  3. Scope is determined lexically (where code is written), not dynamically (where it's called). This enables closures.

  4. this is dynamic: It's determined by how a function is called, not where it's defined. Use bind(), call(), apply(), or arrow functions to control it.

  5. Closures preserve execution contexts. Inner functions can access outer variables even after the outer function returns.

  6. Use let/const instead of var for clearer scoping behavior and to avoid Temporal Dead Zone issues.

  7. Arrow functions don't have their own this—they inherit from the enclosing scope. Use them when you want to preserve this, but avoid them for methods that need dynamic this.

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.

Written by Sandeep Reddy Alalla

Share your thoughts and feedback!