JS Execution Context Deep Dive: Full Tutorial

Understand the JavaScript execution context in depth. Learn the creation and execution phases, variable environment vs lexical environment, the this binding, and how contexts stack on the call stack.

JavaScriptintermediate
13 min read

Every piece of JavaScript code runs inside an execution context. An execution context is the environment that JavaScript creates to run a specific piece of code. It contains the lexical environment (variable bindings), the this binding, and the reference to the outer scope. Understanding execution contexts explains why hoisting exists, how closures work, and what the call stack actually manages.

Types of Execution Contexts

JavaScript creates three types of execution contexts:

TypeCreated WhenContains
Global Execution ContextScript startsGlobal variables, functions, this = globalThis
Function Execution ContextA function is calledLocal variables, parameters, this = depends on call
Eval Execution Contexteval() is calledVariables declared inside eval (avoid using eval)
javascriptjavascript
// --- Global Execution Context starts here ---
const appName = "MyApp";
 
function initialize() {
  // --- Function Execution Context for initialize() ---
  const config = { debug: true };
 
  function loadModules() {
    // --- Function Execution Context for loadModules() ---
    console.log(`Loading modules for ${appName}`);
  }
 
  loadModules();
}
 
initialize();

The Two Phases

Every execution context goes through two phases: creation and execution.

Phase 1: Creation Phase

Before any code in the scope runs, the engine:

  1. Creates the lexical environment with all variable bindings
  2. Sets the this binding
  3. Sets the outer environment reference
javascriptjavascript
function demo() {
  // CREATION PHASE scans this function and builds:
  // LexicalEnvironment: {
  //   multiply: <function reference>  -- function declaration, fully initialized
  //   result: <uninitialized>         -- let, in temporal dead zone
  // }
  // VariableEnvironment: {
  //   count: undefined                -- var, initialized to undefined
  // }
  // ThisBinding: depends on how demo() was called
 
  console.log(count);     // undefined (var is hoisted with value undefined)
  console.log(multiply);  // [Function: multiply] (fully hoisted)
  // console.log(result); // ReferenceError (let is in TDZ)
 
  var count = 5;
  let result = count * 2;
 
  function multiply(a, b) {
    return a * b;
  }
}

Phase 2: Execution Phase

Code runs line by line. Variables are assigned their actual values:

javascriptjavascript
function demo() {
  // --- Execution Phase ---
  // Line 1: count is already undefined from creation phase
  var count = 5;
  // Now: count = 5
 
  let result = count * 2;
  // Now: result = 10 (TDZ is over for result)
 
  function multiply(a, b) {
    return a * b;
  }
  // multiply was already available from creation phase
 
  console.log(count);           // 5
  console.log(result);          // 10
  console.log(multiply(3, 4));  // 12
}
 
demo();

Phase Timeline

Phasevar Variableslet/constFunction Declarationsthis
CreationSet to undefinedExist but uninitialized (TDZ)Fully initializedBound
ExecutionAssigned actual valueAssigned when statement runsAlready availableAlready bound

Lexical Environment vs Variable Environment

Each execution context has two environment components:

  • LexicalEnvironment: Stores let, const, function declarations, and block-scoped bindings
  • VariableEnvironment: Stores var declarations

They start as the same environment but diverge when blocks are entered:

javascriptjavascript
function example() {
  // Both LexicalEnv and VariableEnv point to the function environment
  // VariableEnv: { x: undefined }
  // LexicalEnv: { y: <uninitialized> }
 
  var x = 1;
  let y = 2;
 
  if (true) {
    // A new LexicalEnv is created for this block
    // New LexicalEnv: { z: <uninitialized>, w: <uninitialized> }
    // VariableEnv: still points to function env { x: 1, v: undefined }
 
    let z = 3;
    const w = 4;
    var v = 5; // var goes to VariableEnv (function level)
 
    console.log(x, y, z, w, v); // 1 2 3 4 5
  }
 
  // Back to function-level LexicalEnv
  console.log(x, y, v); // 1 2 5
  // console.log(z);     // ReferenceError
}

The this Binding

The this value is determined during the creation phase based on how the function is called:

javascriptjavascript
// Global context: this = globalThis (window in browsers)
console.log(this === globalThis); // true (in non-strict mode)
 
const obj = {
  name: "Alice",
  greet() {
    // Method call: this = obj (the object before the dot)
    console.log(this.name); // "Alice"
  },
  greetArrow: () => {
    // Arrow function: this = outer this (global)
    console.log(this.name); // undefined
  }
};
 
obj.greet();       // "Alice"
obj.greetArrow();  // undefined
 
function standalone() {
  // Regular function call: this = globalThis (or undefined in strict mode)
  console.log(this);
}
 
standalone(); // globalThis or undefined

this Binding Rules Summary

Call Patternthis ValueExample
Method callThe objectobj.method() -> this === obj
Regular callglobalThis (sloppy) / undefined (strict)fn()
new callNew empty objectnew Fn() -> this === {}
call/apply/bindExplicitly setfn.call(obj) -> this === obj
Arrow functionInherited from lexical scopeNo own this

Execution Context Lifecycle

javascriptjavascript
const name = "Global";
 
function first() {
  const name = "First";
 
  function second() {
    const name = "Second";
    console.log(name); // "Second"
  }
 
  second();
  console.log(name); // "First"
}
 
first();
console.log(name); // "Global"

Step-by-step lifecycle:

CodeCode
1. Engine starts
   -> Create Global Execution Context
   -> Push to call stack
   -> Creation phase: name = <uninitialized>, first = <function>
   -> Execution phase: name = "Global"

2. first() is called
   -> Create Function Execution Context for first()
   -> Push to call stack [Global, first]
   -> Creation phase: name = <uninitialized>, second = <function>
   -> Execution phase: name = "First"

3. second() is called
   -> Create Function Execution Context for second()
   -> Push to call stack [Global, first, second]
   -> Creation phase: name = <uninitialized>
   -> Execution phase: name = "Second", log "Second"

4. second() finishes
   -> Pop second from call stack [Global, first]
   -> second's execution context is destroyed

5. first() continues
   -> log "First"
   -> first() finishes
   -> Pop first from call stack [Global]

6. Global continues
   -> log "Global"

How Closures Work Through Execution Contexts

When a function creates and returns an inner function, the inner function's [[Environment]] holds a reference to the outer function's lexical environment. Even though the outer execution context is popped from the stack, its lexical environment is not garbage collected:

javascriptjavascript
function makeCounter() {
  // EC for makeCounter:
  //   LexicalEnv: { count: 0 }
  //   Outer: Global LexicalEnv
 
  let count = 0;
 
  return function increment() {
    // increment.[[Environment]] = makeCounter's LexicalEnv
    count++;
    return count;
  };
}
 
const counter = makeCounter();
// makeCounter's EC is popped from the stack
// But makeCounter's LexicalEnv is NOT destroyed
// because counter (increment) holds a reference to it
 
counter(); // 1 -- creates new EC for increment, resolves count via [[Environment]]
counter(); // 2 -- new EC, same [[Environment]], count is now 2

Global Execution Context in Detail

The global execution context is special:

javascriptjavascript
// Global Execution Context:
// {
//   LexicalEnvironment: {
//     EnvironmentRecord: {
//       // Object Environment Record (var + function declarations)
//       ObjectRecord: { /* bound to globalThis */ },
//       // Declarative Environment Record (let + const)
//       DeclarativeRecord: { /* NOT on globalThis */ }
//     },
//     OuterEnv: null
//   },
//   VariableEnvironment: {
//     // Points to the Object Environment Record
//   },
//   ThisBinding: globalThis
// }
 
var a = 1;       // -> ObjectRecord -> window.a = 1
let b = 2;       // -> DeclarativeRecord (NOT on window)
function c() {}  // -> ObjectRecord -> window.c = c
 
console.log(window.a); // 1
console.log(window.b); // undefined (b is not on window, let uses DeclarativeRecord)
console.log(window.c); // [Function: c]

Nested Execution Contexts Example

javascriptjavascript
function outer(x) {
  function middle(y) {
    function inner(z) {
      return x + y + z;
    }
    return inner(3);
  }
  return middle(2);
}
 
const result = outer(1); // 6

Each function call creates its own execution context with its own lexical environment:

Call StackActive ECLexicalEnvOuter Reference
outer(1)outer EC{ x: 1, middle: <fn> }Global
middle(2)middle EC{ y: 2, inner: <fn> }outer's LexicalEnv
inner(3)inner EC{ z: 3 }middle's LexicalEnv

Resolving x + y + z in inner:

  • z found in inner's env -> 3
  • y found in middle's env (via outer ref) -> 2
  • x found in outer's env (via outer ref chain) -> 1
  • Result: 6

Strict Mode Effect on Execution Context

javascriptjavascript
"use strict";
 
function strictDemo() {
  // In strict mode:
  // - this is undefined for regular function calls (not globalThis)
  // - Assigning to undeclared variables throws ReferenceError
  // - eval gets its own execution context (cannot modify calling context)
 
  console.log(this); // undefined (NOT globalThis)
 
  // x = 10; // ReferenceError: x is not defined (no implicit global)
}
 
strictDemo();
Rune AI

Rune AI

Key Insights

  • Three types of execution contexts: Global (one per script), function (one per function call), and eval (one per eval call), each with its own lexical environment and this binding
  • Two phases per context: The creation phase scans declarations and sets up bindings (var as undefined, let/const in TDZ, functions fully initialized), then the execution phase runs code line by line
  • LexicalEnvironment vs VariableEnvironment: let/const go in the LexicalEnvironment (block-scoped), var goes in the VariableEnvironment (function-scoped), and they diverge when blocks are entered
  • this is determined by call pattern: Method calls bind to the object, new binds to a fresh object, call/apply/bind set it explicitly, and arrow functions inherit this lexically
  • Closures persist lexical environments: When a function is returned, the execution context is destroyed but the lexical environment stays alive as long as any function references it through [[Environment]]
RunePowered by Rune AI

Frequently Asked Questions

What triggers the creation of a new execution context?

Three things create execution contexts: starting a script (global execution context), calling a function (function execution context), and calling `eval()` (eval execution context). Block statements like `if`, `for`, and `while` do NOT create new execution contexts. They create new [lexical environments](/tutorials/programming-languages/javascript/how-lexical-environment-works-in-javascript) for block-scoped variables, but within the same execution context.

What is the difference between execution context and scope?

[Scope](/tutorials/programming-languages/javascript/javascript-lexical-scope-a-complete-tutorial) refers to the accessibility of variables, where in the code a variable can be referenced. An execution context is the broader runtime environment that includes the scope (lexical environment), the `this` binding, and the connection to the [call stack](/tutorials/programming-languages/javascript/understanding-the-javascript-call-stack-guide). Every execution context contains a lexical environment (which implements scope), but execution context also carries additional state.

What happens to an execution context after the function returns?

When a function returns, its execution context is popped from the call stack and is eligible for garbage collection. However, if any returned or exported function holds a reference to the lexical environment via `[[Environment]]`, that lexical environment survives. Only the execution context wrapper is destroyed. The persisted lexical environment is what we call a [closure](/tutorials/programming-languages/javascript/javascript-closures-deep-dive-complete-guide).

Why does var hoisting give undefined instead of an error?

During the creation phase, `var` declarations are added to the VariableEnvironment and initialized to `undefined`. This is by design from early JavaScript. `let` and `const` were introduced later with the temporal dead zone (TDZ) to fix this behavior. [Accessing `let`/`const`](/tutorials/programming-languages/javascript/var-vs-let-vs-const-js-variable-declarations) before their declaration throws a ReferenceError, which is generally considered the safer and more predictable behavior.

How does the execution context determine the value of this?

The `this` binding is set during the creation phase based on how the function was invoked, not where it was defined. Method calls bind `this` to the object, `new` binds it to a fresh object, `call`/`apply`/`bind` set it explicitly, and regular function calls bind it to `globalThis` (or `undefined` in strict mode). [Arrow functions](/tutorials/programming-languages/javascript/javascript-arrow-functions-a-complete-es6-guide) are the exception. They do not get their own `this` and instead inherit it from the enclosing lexical environment.

Conclusion

Every JavaScript execution creates an execution context with two phases: creation (setting up variable bindings, this, and the outer scope reference) and execution (running code line by line). The lexical environment stores let/const bindings while the variable environment stores var bindings. Closures exist because a returned function's [[Environment]] keeps its parent's lexical environment alive even after the parent's execution context is destroyed.