JavaScript Execution Context – How JS Works Behind The Scenes

As a JavaScript developer, you‘ve likely written hundreds or even thousands of lines of code. But have you ever stopped to think about what actually happens under the hood when your code is executed by the browser or Node.js runtime?

It all comes down to something called the execution context. Understanding JavaScript‘s execution context is key to writing clean, bug-free, and performant code. In this in-depth guide, we‘ll explore exactly what execution contexts are, how they get created, and their implications on your code. Let‘s dive in!

What is an Execution Context?

Simply put, an execution context is an abstract concept that holds information about the environment within which the current code is being executed. You can think of it like a container that stores variables, function declarations, and the current value of the "this" keyword at any point during the execution of your code.

Whenever any code is run in JavaScript, it‘s run inside an execution context. In fact, the JavaScript engine creates a new execution context every time it finds code to execute, whether it‘s global code or code inside a function.

There are two types of execution contexts in JavaScript:

  1. Global Execution Context (GEC)
  2. Function Execution Context (FEC)

Let‘s take a closer look at each of these.

Global Execution Context (GEC)

When a JavaScript file first loads in the browser (or when you run a script in Node.js), the JavaScript engine creates a default execution context called the Global Execution Context (GEC).

The GEC is the base/default execution context. All the global code, meaning any code that is not inside a function, will be executed inside the GEC. Only one GEC can exist at a time for each JavaScript program.

In the browser, the GEC is associated with the window object. So any variables or functions declared globally become properties of the window object. For example:

var name = ‘Alice‘;
function sayHi() { 
  console.log(‘Hello‘);
}

console.log(window.name); // ‘Alice‘
console.log(window.sayHi); // ƒ sayHi() { console.log(‘Hello‘); }

Function Execution Context (FEC)

Whenever a function is invoked, the JavaScript engine creates a new execution context for that function, known as a Function Execution Context (FEC).

Since functions can be called multiple times, and recursively, there can be more than one FEC in the runtime of a script. Each function call gets its own FEC.

Let‘s look at an example:

function foo() {
  console.log(‘foo‘);
  bar();
  console.log(‘foo again‘);
}

function bar() {  
  console.log(‘bar‘);
}

foo();

When this code is executed:

  1. The GEC is created first.
  2. When foo() is called, a new FEC is created for foo.
  3. Inside foo, when bar() is called, another FEC is created for bar.
  4. After bar returns, its FEC is popped off the stack and we return to foo‘s FEC.
  5. After foo returns, its FEC is also popped off, and control returns to the GEC.

We‘ll dive deeper into this flow when we discuss the execution stack. But first, let‘s understand how execution contexts are created.

Creation of an Execution Context

The creation of an execution context happens in two phases:

  1. Creation Phase
  2. Execution Phase

Creation Phase

The creation phase sets up the environment for code execution. In this phase, the JavaScript engine essentially prepares all the necessary information before any code is executed. This involves three main steps:

  1. Creation of the Variable Object
  2. Creation of the Scope Chain
  3. Determination of the value of "this"

Let‘s break down each step:

Variable Object

In the creation phase, the JavaScript engine creates a special object called the Variable Object (VO) for each execution context. The VO is essentially a container that stores:

  • Function declarations (FD): The actual function itself is stored in the VO.
  • Arguments object (in case of FEC): Contains a list of all the arguments passed to the function.
  • Variable declarations (var): Variables are initially set to undefined in the VO.

It‘s important to note that function expressions and variables declared with let and const are not stored in the VO. They are only created in the execution phase.

Here‘s an example:

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;
}

foo(1);

In the creation phase of foo‘s FEC, the VO will look like this:

fooExecutionContext = {
  variableObject: {
    arguments: {
      0: 1,
      length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c()
  },
  scopeChain: { ... },
  this: { ... }
}

Notice how function declaration c is stored directly, while variable d (a function expression) is not. Also, b is initially set to undefined.

Scope Chain

Along with the VO, the JavaScript engine also creates a scope chain during the creation phase. The scope chain contains the VO of the current execution context, as well as the VOs of all its parent execution contexts.

The scope chain is used for variable lookups. If a variable isn‘t found in the current VO, the JavaScript engine looks for it in the VOs of the parent contexts, and so on up the chain until it reaches the GEC.

Here‘s a code snippet to illustrate:

var a = 1;

function foo() {
  var b = 2;
  function bar() {
    var c = 3;
    console.log(a, b, c); // 1 2 3
  }
  bar();
}

foo();

The scope chain for bar‘s FEC would look like this:

barExecutionContext = {
  variableObject: {
    c: 3
  },
  scopeChain: [
    barExecutionContext.variableObject,
    fooExecutionContext.variableObject,
    globalExecutionContext.variableObject
  ],
  this: { ... }
}

This is why bar has access to variables a and b. If one of them wasn‘t found, the JavaScript engine would go up the scope chain to look for it.

Determination of "this"

The final step in the creation phase is determining the value of "this" for the current execution context.

In the GEC, "this" refers to the global object (window in browsers, global in Node.js).

In function contexts, the value of "this" depends on how the function was called. If it was called by an object reference, "this" will be set to that object. Otherwise, "this" will default to the global object or to undefined in strict mode.

Here are some examples:

var obj = {
  prop: 1,
  method: function() {
    console.log(this.prop);
  }
};

obj.method(); // 1 ("this" refers to obj)

var ref = obj.method;
ref(); // undefined (in strict mode)

With all these components in place (VO, scope chain, and "this"), the execution context is ready to move to the execution phase.

Execution Phase

In the execution phase, the JavaScript engine actually runs the code line by line. It assigns real values to variables, executes function calls, and runs any other statements.

Remember how variable declarations in the VO were initially set to undefined? In the execution phase, they are assigned their actual values as the code is run:

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;
}

foo(1);

After the execution phase, the VO will look like this:

fooExecutionContext = {
  variableObject: {
    arguments: {
      0: 1,
      length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(),
    d: reference to function expression
  },
  scopeChain: { ... },
  this: { ... }
}

The code is run in the order it appears, and function calls create new execution contexts that go through their own creation and execution phases.

JavaScript Execution Stack

We‘ve seen that execution contexts are created for both global code and function calls. But how does the JavaScript engine manage these contexts? It uses a stack data structure called the Execution Stack (also known as the Call Stack).

When a script file starts running, the JavaScript engine creates a GEC and pushes it to the currently empty stack. Whenever a function is called, a new FEC is created for that function and pushed to the top of the stack.

The execution context at the top of the stack is the one that is currently running. When a function finishes executing, its FEC is popped off the stack, and control returns to the context below it in the stack.

Let‘s visualize this with a code example:

function foo() {
  bar();
}

function bar() {
  console.log(‘bar‘);
}

foo();

Here‘s how the execution stack will look as this code is run:

  1. GEC is created and pushed to the stack.
  2. foo() is called, FEC for foo is created and pushed to the stack.
  3. Inside foo, bar() is called, FEC for bar is created and pushed to the stack.
  4. bar runs and logs ‘bar‘.
  5. bar finishes, its FEC is popped off the stack.
  6. Control returns to foo, foo finishes, its FEC is popped off the stack.
  7. Control returns to global context.

The execution stack, therefore, helps the JavaScript engine keep track of where it is in the execution of a script.

GEC vs FEC – Key Differences

While the GEC and FECs are similar in many ways, there are some key differences:

  1. There is only one GEC per script, but there can be multiple FECs (one for each function call).

  2. The GEC is created when the script file starts running, before any code is executed. FECs are created during the execution phase when functions are called.

  3. The GEC has access to global variables and global function declarations. Each FEC has access to its own function-scoped variables and arguments, as well as variables from its outer scope(s).

  4. In the browser, the GEC‘s VO is the window object. FECs‘ VOs are simple object literals containing arguments, local variables, and function declarations.

Why Understanding Execution Contexts Matters

Understanding execution contexts is not just an academic exercise. It‘s crucial for writing good, bug-free JavaScript code. Here are a few reasons why:

  1. It helps you understand scope: Knowing how execution contexts and the scope chain work will help you correctly reason about variable visibility and avoid scope-related bugs.

  2. It explains hoisting: Hoisting, a common source of confusion for JavaScript beginners, makes perfect sense once you understand the creation phase of execution contexts.

  3. It clarifies "this" keyword: The value of "this" is set during the creation phase of an execution context. Knowing this can save you from many "this"-related headaches.

  4. It helps with debugging: When your code isn‘t behaving as expected, understanding the execution flow through contexts and the stack can be invaluable for tracking down bugs.

Conclusion

Execution contexts are a fundamental part of how JavaScript works under the hood. Every time you run JavaScript code, you‘re creating and manipulating execution contexts without even realizing it.

By understanding the nitty-gritty of how execution contexts work – the creation and execution phases, variable objects, scope chains, and the execution stack – you‘re not just satisfying an intellectual curiosity. You‘re equipping yourself with the knowledge to write cleaner, more robust, and more maintainable JavaScript code.

So the next time you‘re scratching your head over a strange JavaScript behavior, just think: it‘s probably something to do with execution contexts. Happy coding!

Similar Posts