JavaScript Execution Context: A Complete Tutorial

Learn how JavaScript execution context works step by step. Covers the global execution context, function execution context, creation and execution phases, the call stack, variable environment, this binding, and how closures relate to execution context.

JavaScriptbeginner
12 min read

Execution context is the environment in which JavaScript code runs. Every time you run a script or call a function, JavaScript creates an execution context that manages the code's variables, scope, and this value. Understanding execution context explains why hoisting works, how the scope chain is built, why this changes depending on how you call a function, and how closures maintain access to outer variables.

What is an Execution Context?

An execution context is a container that holds:

  1. Variable Environment: all variables, function declarations, and arguments in this scope
  2. Scope Chain: a reference to the outer (parent) execution context
  3. this binding: the value of this in this context

JavaScript creates three types of execution contexts:

TypeCreated whenthis value
Global Execution Context (GEC)Script startswindow (browser) or globalThis
Function Execution Context (FEC)A function is calledDepends on how the function is called
Eval Execution Contexteval() is calledInherits from calling context

The Global Execution Context

When JavaScript starts executing a script, it first creates the Global Execution Context:

javascriptjavascript
// Global execution context is created automatically
var name = "Alice";
let age = 25;
 
function greet() {
  console.log(`Hello, ${name}!`);
}
 
greet();

The global execution context goes through two phases:

Phase 1: Creation Phase

During the creation phase, the engine:

  1. Creates the global object (window in browsers, global in Node.js)
  2. Creates the this binding (points to the global object)
  3. Sets up memory for all var declarations (initialized to undefined)
  4. Stores function declarations fully in memory
  5. Registers let/const declarations (uninitialized - Temporal Dead Zone)
javascriptjavascript
// After creation phase, before execution:
// name: undefined (var - hoisted and initialized)
// age: <uninitialized> (let - in TDZ)
// greet: function() { ... } (fully stored)
// this: window (browser) or globalThis

Phase 2: Execution Phase

During the execution phase, the engine runs code line by line, assigning values and executing statements:

javascriptjavascript
// Execution proceeds line by line:
// 1. name = "Alice"  (assignment replaces undefined)
// 2. age = 25        (let exits TDZ, gets initialized)
// 3. greet()         (function call -> new execution context)

Function Execution Context

Every time a function is called, a new execution context is created for that function:

javascriptjavascript
var globalVar = "global";
 
function outer() {
  var outerVar = "outer";
 
  function inner() {
    var innerVar = "inner";
    console.log(innerVar);  // "inner"  (own context)
    console.log(outerVar);  // "outer"  (outer context via scope chain)
    console.log(globalVar); // "global" (global context via scope chain)
  }
 
  inner();
}
 
outer();

Function Context Creation

When outer() is called:

  1. New execution context created for outer
  2. Arguments object created with the function's parameters
  3. Variable Environment set up: outerVar = undefined, inner = function
  4. Scope Chain established: outer.[[Scope]] = reference to global context
  5. this binding determined by how outer was called

When inner() is called inside outer:

  1. New execution context created for inner
  2. Variable Environment: innerVar = undefined
  3. Scope Chain: inner -> outer -> global
  4. this binding determined by the call

The Call Stack

The call stack is how JavaScript tracks which execution context is currently running. It follows Last In, First Out (LIFO) order:

javascriptjavascript
function first() {
  console.log("first start");
  second();
  console.log("first end");
}
 
function second() {
  console.log("second start");
  third();
  console.log("second end");
}
 
function third() {
  console.log("third");
}
 
first();

Call Stack Progression

CodeCode
Step 1: [Global]                        // Script starts
Step 2: [Global, first()]               // first() called
Step 3: [Global, first(), second()]     // second() called inside first
Step 4: [Global, first(), second(), third()] // third() called inside second
Step 5: [Global, first(), second()]     // third() returns, popped off
Step 6: [Global, first()]              // second() returns, popped off
Step 7: [Global]                        // first() returns, popped off
Step 8: []                              // Script ends, global popped

Output:

CodeCode
first start
second start
third
second end
first end

Visualizing with Stack Traces

When an error occurs, the stack trace shows the current state of the call stack:

javascriptjavascript
function a() { b(); }
function b() { c(); }
function c() { throw new Error("Something went wrong"); }
 
a();
// Error: Something went wrong
//     at c (script.js:3)
//     at b (script.js:2)
//     at a (script.js:1)
//     at script.js:5

Stack Overflow

The call stack has a finite size. Recursive functions that call themselves without a proper base case exhaust the stack:

javascriptjavascript
function infinite() {
  infinite(); // no base case
}
 
infinite();
// RangeError: Maximum call stack size exceeded
Single-Threaded Execution

JavaScript has a single call stack, meaning it can only execute one piece of code at a time. This is why long-running synchronous operations block the UI. Asynchronous operations (setTimeout, fetch, Promises) use the event loop to avoid blocking the stack.

The Variable Environment in Detail

Each execution context has a Variable Environment that stores identifiers differently based on the declaration type:

javascriptjavascript
function example(param1, param2) {
  var varVariable = "var";
  let letVariable = "let";
  const constVariable = "const";
 
  function innerFunc() {
    return "inner";
  }
}
 
example("a", "b");

Variable Environment after creation phase:

CodeCode
Function Execution Context: example
├── Variable Environment:
│   ├── arguments: { 0: "a", 1: "b", length: 2 }
│   ├── param1: "a"
│   ├── param2: "b"
│   ├── varVariable: undefined       (var - hoisted, initialized)
│   ├── letVariable: <uninitialized> (let - TDZ)
│   ├── constVariable: <uninitialized> (const - TDZ)
│   └── innerFunc: function() { ... } (fully hoisted)
├── Scope Chain: [example scope, global scope]
└── this: (depends on call method)

After execution phase:

CodeCode
├── Variable Environment:
│   ├── param1: "a"
│   ├── param2: "b"
│   ├── varVariable: "var"
│   ├── letVariable: "let"
│   ├── constVariable: "const"
│   └── innerFunc: function() { ... }

The this Binding

Each execution context determines its this value based on how the function was called:

javascriptjavascript
// Global context: this = window (browser) or globalThis
console.log(this === window); // true (in browser)
 
// Function call: this = window (non-strict) or undefined (strict)
function standalone() {
  console.log(this);
}
standalone(); // window (non-strict) or undefined (strict)
 
// Method call: this = the object
const user = {
  name: "Alice",
  greet() {
    console.log(this.name);
  },
};
user.greet(); // "Alice" (this = user)
 
// Constructor call: this = new object
function Person(name) {
  this.name = name;
}
const p = new Person("Bob"); // this = new Person instance
 
// Explicit binding: this = specified object
function sayName() {
  console.log(this.name);
}
sayName.call({ name: "Charlie" }); // "Charlie"

Arrow Functions and this

Arrow functions do not create their own this binding. They inherit this from the enclosing execution context:

javascriptjavascript
const team = {
  name: "Developers",
  members: ["Alice", "Bob"],
  printMembers() {
    // Regular function: 'this' from method call = team
    this.members.forEach((member) => {
      // Arrow function: inherits 'this' from printMembers context
      console.log(`${member} is in ${this.name}`);
    });
  },
};
 
team.printMembers();
// "Alice is in Developers"
// "Bob is in Developers"

Scope Chain and Execution Context

The scope chain is established during the creation phase of each execution context. It links to the Variable Environment of the outer (lexical parent) context:

javascriptjavascript
const global = "G";
 
function outer() {
  const outerVar = "O";
 
  function middle() {
    const middleVar = "M";
 
    function inner() {
      const innerVar = "I";
 
      // Scope chain resolution:
      console.log(innerVar);  // found in inner's Variable Environment
      console.log(middleVar); // found in middle's Variable Environment
      console.log(outerVar);  // found in outer's Variable Environment
      console.log(global);    // found in global Variable Environment
    }
 
    inner();
  }
 
  middle();
}
 
outer();

Scope Chain Diagram

CodeCode
inner EC
├── Variable Environment: { innerVar: "I" }
├── Scope Chain: inner -> middle -> outer -> global
└── Lookup: innerVar ✓ -> middleVar ✓ -> outerVar ✓ -> global ✓

middle EC
├── Variable Environment: { middleVar: "M", inner: function }
├── Scope Chain: middle -> outer -> global

outer EC
├── Variable Environment: { outerVar: "O", middle: function }
├── Scope Chain: outer -> global

Global EC
├── Variable Environment: { global: "G", outer: function }
├── Scope Chain: global (end of chain)

Closures and Execution Context

A closure occurs when a returned function maintains a reference to its outer execution context's Variable Environment, even after the outer function has finished executing:

javascriptjavascript
function createCounter() {
  let count = 0; // stored in createCounter's Variable Environment
 
  return function increment() {
    count++;
    return count;
  };
}
 
const counter = createCounter();
// createCounter's execution context is popped from the stack
// BUT its Variable Environment is kept alive by the closure
 
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

What Happens Step by Step

  1. createCounter() is called - new FEC created
  2. count is initialized to 0 in the Variable Environment
  3. increment function is created with a reference to createCounter's scope
  4. increment is returned
  5. createCounter's FEC is popped from the call stack
  6. But createCounter's Variable Environment stays in memory (referenced by increment)
  7. Each call to counter() creates a new FEC for increment, which accesses count through its scope chain

Complete Example: Tracing Execution

javascriptjavascript
var x = 10;
 
function foo() {
  var y = 20;
 
  function bar() {
    var z = 30;
    console.log(x + y + z);
  }
 
  bar();
}
 
foo();

Full Trace

CodeCode
1. Global EC created
   Variable Environment: { x: undefined, foo: function }
   this: window

2. Execution starts
   x = 10

3. foo() called - new FEC pushed
   Call Stack: [Global, foo]
   Variable Environment: { y: undefined, bar: function }
   Scope Chain: foo -> global
   this: window

4. y = 20

5. bar() called - new FEC pushed
   Call Stack: [Global, foo, bar]
   Variable Environment: { z: undefined }
   Scope Chain: bar -> foo -> global
   this: window

6. z = 30

7. console.log(x + y + z)
   z found in bar's VE: 30
   y found in foo's VE (via scope chain): 20
   x found in global VE (via scope chain): 10
   Output: 60

8. bar() returns - FEC popped
   Call Stack: [Global, foo]

9. foo() returns - FEC popped
   Call Stack: [Global]

10. Script ends - Global EC removed
    Call Stack: []

The Event Loop and Execution Context

When asynchronous code runs, the event loop manages which code gets a new execution context and when:

javascriptjavascript
console.log("1: Synchronous");
 
setTimeout(function timer() {
  console.log("2: setTimeout callback");
}, 0);
 
Promise.resolve().then(function micro() {
  console.log("3: Promise microtask");
});
 
console.log("4: Synchronous");
 
// Output:
// 1: Synchronous
// 4: Synchronous
// 3: Promise microtask
// 2: setTimeout callback

Why this order?

  1. Synchronous code runs first in the current execution context
  2. Microtasks (Promises) run after the current context but before macrotasks
  3. Macrotasks (setTimeout) run after all microtasks are completed

Each callback (timer, micro) gets its own Function Execution Context when it runs.

Rune AI

Rune AI

Key Insights

  • Two phases: creation (memory setup, hoisting) and execution (line-by-line code running)
  • Three parts: Variable Environment, scope chain, and this binding
  • Call stack: tracks nested function calls, LIFO order, finite size
  • Scope chain: links each context to its lexical parent for variable resolution
  • Closures: inner functions keep their outer context's Variable Environment alive
  • this varies: global, method, constructor, and explicit calls each set this differently
RunePowered by Rune AI

Frequently Asked Questions

Is the execution context the same as scope?

No. Scope determines which variables are accessible from a given location in the code. Execution context is the runtime environment that includes the Variable Environment (where scoped variables live), the scope chain, and the `this` binding. Scope is a subset of what execution context manages.

How many execution contexts can exist at once?

There is always exactly one execution context actively running (the top of the call stack). But many can exist in the stack simultaneously. Each nested function call adds one. The [stack limit](/tutorials/programming-languages/javascript/preventing-stack-overflow-in-javascript-recursion) is typically 10,000-15,000 contexts.

Does each arrow function get its own execution context?

Yes, [arrow functions](/tutorials/programming-languages/javascript/when-to-avoid-using-arrow-functions-in-javascript) get their own execution context with their own Variable Environment. The difference is that arrow functions do not get their own `this` binding - they inherit `this` from the enclosing context.

What happens to execution context when an error is thrown?

When an error is thrown, JavaScript unwinds the call stack, popping execution contexts one by one until it finds a try/catch block. If no catch is found, the error reaches the global context and becomes an unhandled error.

Conclusion

Every piece of JavaScript code runs inside an execution context. The global execution context is created when a script starts. Each function call creates a new function execution context. Every context has a Variable Environment (storing variables and functions), a scope chain (linking to parent contexts), and a this binding. The call stack tracks which context is active, following last in, first out order. Understanding execution context explains hoisting (creation phase), scope chains (Variable Environment references), and closures (preserved Variable Environments). Use const and let for predictable behavior, and recognize that this is determined by how a function is called, not where it is defined.