JavaScript Execution Context

An execution context is what the JavaScript engine uses to track the runtime evaluation of code.

Having a solid understanding of execution context in JavaScript is essential in order to understand other fundamental concepts developers often find confusing such as scope, closures and hoisting. Although execution context is not a feature you can access directly in code, it is a concept defined by the ECMAScript specification detailing how the engine evaluates and executes your programs.

How the JavaScript engine executes code

The first thing to understand is the JavaScript engine executes code in two phases:

  1. Compilation phase
  2. Execution phase
When reading the spec, there will be terms static sematics and runtime semantics. These refer to operations that run during the compilation phase and execution phase, respectively.

Compilation Phase

Compilation consists of a few different phases. Before our code can be executed it needs to be broken up into tokens, this is typically referred to as tokenizing/lexing. Next the parser takes in the array of tokens and creates an abstract syntax tree or ast. The next phase is code generation which is the process of transforming the ast into executable code.

The following example demonstrates that JavaScript performs a separate compilation phase before execution. Because a syntax error is detected during compilation, the code never reaches the execution phase, and console.log is never run.

let a = 'a'

console.log(a) // this never runs

a = .'a' // Uncaught SyntaxError: Unexpected token '.'

Notice how 'a' is never logged to the console. The only way the JavaScript engine could know about this error is if the entire program was parsed first. If it was ran from the top down, the console.log(a) would run, then an error would be thrown.

The same thing happens below even though the function is never invoked.

const a = 'a'
console.log(a)

function foo() {
  let b = 'b'
  console.log(b)
  b = .'b' // Uncaught SyntaxError: Unexpected token '.'
}

Here is another example, we will declare let twice in the same scope and not invoke the funtion. This should make it very clear that there are indeed two phases, if there is an error no code execution will begin.

function foo() {
  let a = 1
  let a = 2
}
// Uncaught SyntaxError: Identifier 'a' has already been declared

Lexical Analysis (Lexing/Scanning)

The first step in processing JavaScript code is lexical analysis. This involves reading the raw source code character by character and grouping them into meaningful units called tokens. These tokens represent the basic building blocks of the language, such as if, while, const, let, +, =, {}, () ect.

The ECMAScript specification, which defines JavaScript, includes a detailed "Lexical Grammar" section. This grammar precisely defines how characters are grouped into tokens, including rules for identifiers, keywords, literals, and how whitespace and line terminators are handled.

The lexer is what implements the lexical grammar.

We can see the below code block is a simple variable declaration, but for the program to understand this it first needs to be broken up.

const x = 1

This will be broken up into tokens const, x, =, and 1.

Syntax Analysis (Parsing)

The engine feeds the token stream to the parser, which constructs an abstract syntax tree (AST) - a hierarchical representation of the program’s syntactic structure. During this step, the parser enforces JavaScript’s syntactic grammar. If a violation is found, compilation stops with a SyntaxError.

The actual resolution of scopes and identifier bindings is performed later during the semantic analysis phase.

This is where the term lexical Scope comes from. Lexical scope is defined by the physical location of where functions and variables are declared in the code.

Semantic Analysis

A semantic analyzer primarily augments the AST produced by the parser. While traversing the AST it constructs and uses a scope tree, often implemented as a stack of symbol tables to resolve identifiers to their bindings and annotate the AST. This allows it to verify that each identifier is declared before it is used, resolve references to the correct declaration when an identifier is shadowed in an inner scope, and report duplicate declarations within the same scope.

When the analyzer enters a new scope, such as a function or block, it pushes a new symbol table onto the stack to represent that scope. Declarations in that scope create new bindings, which are added to the current table. References to identifiers are resolved by searching the current symbol table first, then moving upward through the stack until the appropriate binding is found. When the analyzer leaves the scope it pops the corresponding symbol table off the stack.

At this stage, scopes and bindings exist only as part of the program’s static structure. Memory for variables is allocated later during execution, when the runtime creates environment records for each scope according to the layout determined during semantic analysis.

In the code below, the analyzer associates declarations with their correct lexical scopes. const x = 1 belongs to the function scope, while const x = 2 belongs to the inner block scope created by the if statement. If two const x declarations appeared in the same block, the analyzer would report a SyntaxError at compile time.

function foo() {
  const x = 1

  if (x > 0) {
    const x = 2
    console.log(x)
  }
}

Execution Phase

Once the JavaScript engine has parsed the source code and generated the executable code during the compilation phase, it proceeds to the execution phase, where that code is evaluated within execution contexts.

Execution Context

Anytime a function is invoked in JavaScript, a new execution context is created and pushed onto the top of the execution context stack, or callstack. When viewing the callstack using a debugger statement, each stack frame in the call stack is an execution context.

The topmost entry in the callstack is referred to by the spec as the running execution context. The job of the callstack is to track the execution contexts.

Here is how the spec details this:

An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation. At any point in time, there is at most one execution context per agent that is actually executing code. This is known as the agent's running execution context. All references to the running execution context in this specification denote the running execution context of the surrounding agent.

The execution context stack is used to track execution contexts.

At any point in time, the running execution context may be suspended and a different execution context will become the running execution context, for example:

JavaScript
function foo() {
  debugger
  // 1. foo is the running execution context

  function bar() {
    // 3. foo is suspended and bar is the running execution context
    debugger
    console.log('bar')
  }

  bar() // 2. a new execution context is pushed onto the callstack

  // 4. bar is popped off the callstack and foo is the running execution context
  console.log('foo')
}

foo()

Execution Context Components

Each execution context contains certain state components which are pointers to environment records. Two of these state components are the LexicalEnvironment and the VariableEnvironment.

Lexical Environment

The LexicalEnvironment state component of the execution context identifies the environment record used to resolve identifier references made by code within this execution context.

An environment record is used to define the association of identifiers to specific variables and functions based upon the lexical nesting structure of the code. Each environment record has an [[OuterEnv]] field which is null for the global scope or a reference to an outer environment record.

Variable Environment

The VariableEnvironment state component of the execution context is a pointer to the Environment Record that holds bindings created by var declarations within this execution context.

A var statement declares variables that are scoped to the running execution context's VariableEnvironment. Var variables are created when their containing Environment Record is instantiated and are initialized to undefined when created. Within the scope of any VariableEnvironment a common BindingIdentifier may appear in more than one VariableDeclaration but those declarations collectively define only one variable. A variable defined by a VariableDeclaration with an Initializer is assigned the value of its Initializer's AssignmentExpression when the VariableDeclaration is executed, not when the variable is created.

Types of Execution Context

There are different types of execution contexts but they are track the runtime evaluation of code nonetheless.

In the spec ECMAScript code execution contexts is just a more specific execution context that runs actual JavaScript source code and therefore needs LexicalEnvironment and VariableEnvironment state components.

Original Execution Context

The global execution context is credited with setting up the global environment, but this is not the case. Before the global execution context is created in ScriptEvaluation, an operation runs which creates and sets up the global environment and global object.

The name of this operation is InitializeHostDefinedRealm which is responsible for creating the original execution context. Nowhere else in the spec is InitializeHostDefinedRealm called because this operation acts as an entry point for a hosting environment.

This is not visible in the dev tools callstack pane, we can only see (anonymous) which represents the global execution context.

Notice on line 9 what happens, SetRealmGlobalObject is executed, then inside that operation the operation NewGlobalEnvironment is called and that returns a new global environment record.

9.3.1 InitializeHostDefinedRealm ( )

  1. Let realm be a new Realm Record.
  2. Perform CreateIntrinsics(realm).
  3. Set realm.[[AgentSignifier]] to AgentSignifier().
  4. Set realm.[[TemplateMap]] to a new empty List.
  5. Let newContext be a new execution context.
  6. Set the Function of newContext to null.
  7. Set the Realm of newContext to realm.
  8. Set the ScriptOrModule of newContext to null.
  9. Push newContext onto the execution context stack; newContext is now the running execution context.
  10. If the host requires use of an exotic object to serve as realm's global object, then a. Let global be such an object created in a host-defined manner.
  11. Else, a. Let global be OrdinaryObjectCreate(realm.[[Intrinsics]].[[%Object.prototype%]]).
  12. If the host requires that the this binding in realm's global scope return an object other than the global object, then a. Let thisValue be such an object created in a host-defined manner.
  13. Else, a. Let thisValue be global.
  14. Set realm.[[GlobalObject]] to global.
  15. Set realm.[[GlobalEnv]] to NewGlobalEnvironment(global, thisValue).
  16. Perform ? SetDefaultGlobalBindings(realm).
  17. Create any host-defined global object properties on global.
  18. Return unused.

9.1.2.5 NewGlobalEnvironment ( G, thisValue )

The abstract operation NewGlobalEnvironment takes arguments G (an Object) and thisValue (an Object) and returns a Global Environment Record. It performs the following steps when called:

  1. Let objRec be NewObjectEnvironment(G, false, null).
  2. Let dclRec be NewDeclarativeEnvironment(null).
  3. Let env be a new Global Environment Record.
  4. Set env.[[ObjectRecord]] to objRec.
  5. Set env.[[GlobalThisValue]] to thisValue.
  6. Set env.[[DeclarativeRecord]] to dclRec.
  7. Set env.[[OuterEnv]] to null.
  8. Return env.

Global Execution Context

Anytime a code is evaluated there is always the global execution context, such as a for loop in global scope.

Every execution context has a field [[ScriptOrModule]].

From the spec:

ScriptOrModule state component for all execution contexts

The Module Record or Script Record from which associated code originates. If there is no originating script or module, as is the case for the original execution context created in InitializeHostDefinedRealm, the value is null.

The new global execution context, scriptContext, is pushed onto the execution context stack and becomes the running execution context. If this were in chrome dev tools, you would only see this new global execution context as (anonymous).

Notice scriptContext is the global execution context, and step 9 confirms there is a running execution that sits below the global execution context.

16.1.6 ScriptEvaluation ( scriptRecord )

The abstract operation ScriptEvaluation takes argument scriptRecord (a Script Record) and returns either a normal completion containing an ECMAScript language value or an abrupt completion. It performs the following steps when called:

  1. Let globalEnv be scriptRecord.[[Realm]].[[GlobalEnv]].
  2. Let scriptContext be a new ECMAScript code execution context.
  3. Set the Function of scriptContext to null.
  4. Set the Realm of scriptContext to scriptRecord.[[Realm]].
  5. Set the ScriptOrModule of scriptContext to scriptRecord.
  6. Set the VariableEnvironment of scriptContext to globalEnv.
  7. Set the LexicalEnvironment of scriptContext to globalEnv.
  8. Set the PrivateEnvironment of scriptContext to null.
  9. Suspend the running execution context.
  10. Push scriptContext onto the execution context stack; scriptContext is now the running execution context.
  11. Let script be scriptRecord.[[ECMAScriptCode]].
  12. Let result be Completion(GlobalDeclarationInstantiation(script, globalEnv)).
  13. If result is a normal completion, then a. Set result to Completion(Evaluation of script). b. If result is a normal completion and result.[[Value]] is empty, then i. Set result to NormalCompletion(undefined).
  14. Suspend scriptContext and remove it from the execution context stack.
  15. Assert: The execution context stack is not empty.
  16. Resume the context that is now on the top of the execution context stack as the running execution context.
  17. Return ? result.

Function Execution Context

A function execution context is created any time a function is invoked and there can be any number of function execution contexts in a JavaScript program.

The spec details how a function execution context is created below.

10.2.1.1 PrepareForOrdinaryCall ( F, newTarget )

The abstract operation PrepareForOrdinaryCall takes arguments F (an ECMAScript function object) and newTarget (an Object or undefined) and returns an execution context. It performs the following steps when called:

  1. Let callerContext be the running execution context.
  2. Let calleeContext be a new ECMAScript code execution context.
  3. Set the Function of calleeContext to F.
  4. Let calleeRealm be F.[[Realm]].
  5. Set the Realm of calleeContext to calleeRealm.
  6. Set the ScriptOrModule of calleeContext to F.[[ScriptOrModule]].
  7. Let localEnv be NewFunctionEnvironment(F, newTarget).
  8. Set the LexicalEnvironment of calleeContext to localEnv.
  9. Set the VariableEnvironment of calleeContext to localEnv.
  10. Set the PrivateEnvironment of calleeContext to F.[[PrivateEnvironment]].
  11. If callerContext is not already suspended, suspend callerContext.
  12. Push calleeContext onto the execution context stack; calleeContext is now the running execution context.
  13. NOTE: Any exception objects produced after this point are associated with calleeRealm.
  14. Return calleeContext.

Environment Records

There are a few different types of environment records detailed in the spec. If you take a look at the link the spec gives us this information:

Declarative Environment Record

A Declarative Environment Record is used to define the effect of ECMAScript language syntactic elements such as FunctionDeclarations, VariableDeclarations, and Catch clauses that directly associate identifier bindings with ECMAScript language values.

Function Environment Record

Function Environment Record corresponds to the invocation of an ECMAScript function object, and contains bindings for the top-level declarations within that function. It may establish a new this binding. It also captures the state necessary to support super method invocations

Module Environment Record

A Module Environment Record contains the bindings for the top-level declarations of a Module. It also contains the bindings that are explicitly imported by the Module. Its [[OuterEnv]] is a Global Environment Record.

Object Environment Record

An Object Environment Record is used to define the effect of ECMAScript elements such as WithStatement that associate identifier bindings with the properties of some object.

Global Environment Record

A Global Environment Record is used for Script global declarations. It does not have an outer environment; its [[OuterEnv]] is null. It may be prepopulated with identifier bindings and it includes an associated global object whose properties provide some of the global environment's identifier bindings. As ECMAScript code is executed, additional properties may be added to the global object and the initial properties may be modified.

If paying attention, there is something interesting to be noted here. There are four types of scope:

  • global scope
  • function scope or local scope
  • module scope
  • block scope

If the concept of scope has ever seemed confusing, hopefully this will shed some light on what scope is in JavaScript, scope is simply an environment record.

The scopes from the semantic analysis pass are instantiated at runtime, and this runtime instantiation is referred to as an environment record.

We can take a look at what a declarative environment record is specifically since it binds identifiers defined by declarations like const x = 1 in its scope.

From the spec

Each Declarative Environment Record is associated with an ECMAScript program scope containing variable, constant, let, class, module, import, and/or function declarations. A declarative environment record binds the set of identifiers defined by the declarations contained within its scope.

What this means is that any time there is a declaration made, for example const someLetter = 'a' with const, let, function or anything else listed above, there is a declarative environment record associated with it, and it binds the identifiers to their values.

In the code below, someLetter is an identifier. A binding is created for this identifier in the environment record, which associates the name someLetter with the value 'a'. A binding is simply an entry inside the environment record that connects an identifier name to a value. It's important to understand that the variable itself is not the value — it is a named container that holds a value through this binding.
JavaScript
const someLetter = 'a'
var someNumber = 1

function foo() {
  console.log('foo')
}
let and const belong to the LexicalEnvironment, not the VariableEnvironment.

Here is a small and simple pseudocode example to help visualize this concept.

JavaScript
GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      someNumber: 1
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      someLetter: 'a',
      foo: <ref. to foo function object>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

For Loops do not create a new execution context

A for loop in global scope will be evaluated in a global execution context because any time any code is evaluated, there is always at least the global scope. For each iteration of the for loop a new environment record is instantiated and the global execution context's lexical environment component will be pointed to the new environment record. That means for each iteration there are brand new variables being created for let and const.

The .forEach() method executes a provided function once for each array element. Therefore, for each callback invocation a new execution context is created along with a new environment record for that execution context.

Blocks do not create a new execution context

When a block is evaluated, a new Declarative Environment Record will be created and the running execution context's LexicalEnvironment state component will be pointed to the new Declarative Environment Record. When evaluation leaves the block, the running execution context's LexicalEnvironment component will be pointed back to the previous environment record before the block was evaluated.

Example

JavaScript
const a = 1
let b = 2
var c = 3

function foo() {
  const a = 1

  if (a > 0) {
    const a = 3
    return a + b + c
  }
}

foo()

At this point the code has already been compiled and a global execution context is created. For the global execution context the following steps occur:

  1. A global execution context will be created
  2. The LexicalEnvironment and VariableEnvrionment components of the global execution context will be set up and pointed to the global environment record (scope)
  3. The running execution context will be suspended. This running execution context is the original execution context we talked about earlier that was responsible for setting up the global object, global bindings, ect
  4. The global execution context will be pushed onto the execution context stack and will become the running execution context.
  5. All the global declarations will be instantiated
    1. creates bindings that are uninitialized for let and const.
    2. if there are functions to initialize, instantiates the function objects.
      1. adds all the internal methods the function object needs to be considered a function. [[Call]] for example.
    3. creates a mutable binding in the Object Environment Record, meaning the function declaration is accessible on the window, and initializes to its function object which was returned from the previous step.
    4. creates mutable bindings that are initialized to undefined for var declarations in the object environment record. If it already exists, it's reused and assumed to be intialized.
  6. Code evaluation will begin and when a declaration is evaluated, the binding will be intialized for let and const, for var the binding will be re-assigned to the respective value.
  7. after the code has finished evaluating, suspend the global execution context and and remove it from the global execution context stack. the original execution context is now the running execution context.
  8. if there is a value return it

For the function execution context the following steps occur:

  1. During runtime if a set of parenthesis ( ) is encountered, the internal [[Call]] method of the function object is called.
  2. a new function execution context is created.
  3. a new function environment record or function scope is created
  4. the LexicalEnvironment and VariableEnvironment are pointed to the function environment record which was just created.
  5. running execution context is suspended.
  6. this new execution context is pushed onto the execution context stack or callstack
  7. this value is set (OrdinaryCallBindThis)
  8. creates bindings that are uninitialized for let and const.
  9. if there are functions to initialize, instantiates the function objects.
    1. adds all the internal methods the function object needs to be considered a function. [[Call]] for example.
    2. creates a mutable binding in the Object Environment Record, meaning the function declaration is accessible on the window, and initializes to its function object which was returned from the previous step.
  10. creates mutable bindings that are initialized to undefined for var declarations in the object environment record. If it already exists, it's reused and assumed to be intialized.
  11. code evaluation will begin and when a declaration is evalutated, the binding will be initialized for let and const, for var the binding will be re-assigned to the respective value.
  12. remove this execution context from the execution context stack and restore the previous execution context as the running execution context.
  13. if there is a return value, return it

This can be roughly illustrated with the following pseudocode.

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>, // e.g., window in browsers
    Bindings: {
      // Built-in globals (e.g., Math, Array, JSON, etc.)
      c: undefined
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: unintialized,
      b: unintialized,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

Everything is now setup, all the declarations are hoisted, including let and const, they are just uninitialized and evaluation begins. Once line 1 is evaluated the const a = 1 variable is initialized through assignment to 1.

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      c: undefined
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: 1,
      b: unintialized,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

line 2 is evaluated and b is initialized through assignment.

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      c: undefined
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: 1,
      b: 2,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

line 3 is evaluated and reassigned to 3

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      c: 3
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: 1,
      b: 2,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

line 14 is evaluated. There is nothing to do on line 10 because the function object was already created and registered in the environment record. On line 14, while the global execution context is evaluating code, the function reference foo is found with () so the Call() method is called which calls the function object's internal [[Call]] method, this creates a new exeuction context, instantiates a new function environment (function scope), points the execution context's LexicalEnvironment and VariableEnvironment state components to the newly instantiated function environment record, suspends the running execution context, this new execution context is pushed onto the the execution context stack and becomes the running execution context, all the variables that were registered to the respected scope by looking at the AST from the parsing stage of compilation are created and code will start to evaluate.

Variables and scope have been instantiated and the program looks like this right before evaluation begins. Remember that the Abstract Syntax Tree (AST) acts as a runtime blue print. It maps all the scopes and variables but doesn't allocate memory or instantiate anything until runtime.

FooExecutionContext = {
  LexicalEnvironment: FooEnvironmentRecord,
  VariableEnvironment: FooEnvironmentRecord
}

FooEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  Bindings: {
    a: uninitialized
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to foo>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      c: 3
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: 1,
      b: 2,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

line 6 is evaluated and const a is initialized through assigment to 1.

FooExecutionContext = {
  LexicalEnvironment: FooEnvironmentRecord,
  VariableEnvironment: FooEnvironmentRecord
}

FooEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  Bindings: {
    a: 1
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to foo>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      c: 3
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: 1,
      b: 2,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

line 8 evaluates to true so the code inside the block is executed. What happens next is block evaluation, there is no new execution context created.

A new declarative environment record (scope) is created, bindings for let and const are created that are unintialized, the running execution context's LexicalEnvironment will be pointed to the new environment record that was just created, the block will begin to be evaluated.

Right before the block begins evaluation, it will look like this:

FooExecutionContext = {
  LexicalEnvironment: NewFooEnvironmentRecord,
  VariableEnvironment: FooEnvironmentRecord
}

NewFooEnvironmentRecord = {
  type: DeclarativeEnvironmentRecord,
  Bindings: {
    a: uninitialized
  },
  [[OuterEnv]]: FooEnvironmentRecord
}

FooEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  Bindings: {
    a: 1
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to foo>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      c: 3
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: 1,
      b: 2,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

Evaluation begins and line 9 is evaluated, a is initialized to 3 through assignment.

FooExecutionContext = {
  LexicalEnvironment: NewFooEnvironmentRecord,
  VariableEnvironment: FooEnvironmentRecord
}

NewFooEnvironmentRecord = {
  type: DeclarativeEnvironmentRecord,
  Bindings: {
    a: 3
  },
  [[OuterEnv]]: FooEnvironmentRecord
}

FooEnvironmentRecord = {
  type: FunctionEnvironmentRecord,
  Bindings: {
    a: 1
  },
  [[ThisValue]]: window, // for browsers
  [[ThisBindingStatus]]: "initialized",
  [[FunctionObject]]: <reference to foo>,
  [[NewTarget]]: undefined,
  [[OuterEnv]]: GlobalEnvironmentRecord
}

GlobalExecutionContext = {
  LexicalEnvironment: GlobalEnvironmentRecord,
  VariableEnvironment: GlobalEnvironmentRecord
}

GlobalEnvironmentRecord = {
  Type: GlobalEnvironmentRecord,
  [[ObjectRecord]]: {
    Type: ObjectEnvironmentRecord,
    [[BindingObject]]: <global object>,
    Bindings: {
      c: 3
    },
    [[OuterEnv]]: null
  },
  [[DeclarativeRecord]]: {
    Type: DeclarativeEnvironmentRecord,
    Bindings: {
      a: 1,
      b: 2,
      foo: <ref. to foo>
    },
    [[OuterEnv]]: null
  },
  [[GlobalThisValue]]: <global object>,
  [[OuterEnv]]: null
}

line 10 is evaluated, the return statement causes the function to cease evaluation and returns the value 8. foo is removed from the execution context stack and the global execution context is restored as the running execution context. There is nothing more for the global execution context to evaluate so it is also removed from the execution context stack.

One important thing to note, if foo were called a second time, on line 15 for example, the whole process would repeat all over again, each time the function foo is invoked, a new execution context is created and variables are created, hoisted, and initialized all over again. When the code for the associated execution context finishes being evaluated, it is removed from the execution context stack and is garbage collected, except in the case of closures.

Conclusion

Execution contexts are used to track the runtime evaluation of code. By the time execution begins, most of the work is already done. The source code has been parsed into an AST, the scope tree has been built, and the compiler has generated the executable bytecode.

JavaScript’s lexical scoping means variable visibility is fixed by the code’s structure at author time, and the compiler records that scope information during compilation.

When the program runs the engine is executing the compiled code. As each global script, function call, or eval is executed, the engine creates a new execution context to keep track of that runtime state. The scopes are instantiated as environment records at runtime, but they were already identified at compile time.