How JavaScript Execution Context and Hoisting Works

JavaScript is easy . Until it breaks — then nothing makes sense. Console logs tell you it’s undefined in that annoying little red error. Or things just get set incorrectly, even though you think your logic is infallible. It happens a lot, especially for beginners.

So what exactly went wrong?

When something blips out in your JavaScript code, it’s probably because you’ve messed up the execution context in some way.

People say that execution context is an advanced concept — but I think it is fundamental to being a robust JavaScript developer because it governs the way your code runs.

When you understand execution contexts, you understand how the JavaScript interpreter sees the code rather than how you visually interpret it.

What Exactly is an Execution Context?

In theory, you could write your JavaScript code as one big linear procedural chain. However, for human comprehension purposes, this is not the most effective method.

Packages, methods, functions and whatever else you use to segment and group ideas together, the execution context how the JavaScript compiler manages the complexity of running the code.

A context is the boundary space that code is evaluated in. In JavaScript, there is a thing called a global execution context and it gets executed every time — even when there’s no code to run.

When the JavaScript interpreter runs, it goes through two phases — a creation phase and an execution phase. These phases are repeated every time a block of code is called.

At the start, the JavaScript interpreter always creates a global execution context — even when there’s no code to run — consisting of two properties: the window object and a this object that points back to the window object.

When there are functions and variables declared, the JavaScript interpreter makes physical space for them in memory during the creation phase. It then executes the code from top to bottom, assigning values and running functions as necessary.

Take a look at the code below:

let cat = 'Tibbers';
console.log(this.cat); //will return undefined
console.log(cat); //will return Tibbers because cat is its own object

The first console log will return undefined , not because it doesn’t exist, but because the value hasn’t been assigned yet. Memory space has been allocated by the creation phase of the execution context, but the assignment is still yet to occur.

The second console log will return Tibbers as expected because in the linear process, the variable cat has been assigned a value.

In the above example, var is used as the initializer. It’s good to note that when var is used, memory space is allocated during the creation phase. If we use let, memory space is created during the execution phase. This means that if we try and console log something before it was declared, the console would spit back an angry red error that looks something like this:


What About Functions?

When functions come into play, they’re allocated memory space but the code inside is not executed until it they’re called. When a function is executed, it goes through the same creation and execution phases as the global context.

During the creation phase inside a function, two objects are created by default. The object this comes into existence, in addition to arguments — which is an array like object that contains the values of the arguments passed into that function.

function cat(name){
  console.log(arguments);
}

cat('Tibbers');

//the log will return something like this: {0: "Tibbers"}, 
//meaning that you could access it like this: arguments[0] to just get "Tibbers"

The creation phase inside a function goes through each declared variable and creates memory space for it. Once this is complete, it runs the execution phase and assigns everything in the order based on when they appear.

If you have nested functions, this where the game of inception begins.

It’s Always Been About the Context

Let’s start by taking a look at the code below.

function cat(){
  console.log('in cat()');

  function jump(){
    console.log('in jump()');
  
    function jumpAgain(){
      console.log('in jumpAgain()');
    }
    
    jumpAgain();
  }
  
  jump();
}

cat();

disclaimer: it is not recommended that you nest functions. The example above is for execution context demonstration purposes only.

In this code the JavaScript interpreter starts off with the Global execution context creation phase, resulting in a window global object, this that points to window and cat object that declares itself as a function.

Next, it hits the execution phase and assigns values to variables, of which we have none.

Once all that is completed, cat() is invoked (i.e. it is called) and results in a cat execution context that runs a creation phase, making memory space for arguments, this and the function jump().

When the creation phase is completed the execution phase takes over. Variables are assigned, block statements run and whatever else needs to be done, in the same manner as the global execution context. In the code above, console.log('in cat()') is run and jump() is executed.

The cycle of execution context creation begins again with jump, resulting in another set of arguments, this and jumpAgain function in allocated memory space.

During the execution phase, jumpAgain runs and voila! another execution context is created and cycles through the process of creation and execution phases. When this is done, and as there is nothing left to run, memory is released back into the pool, the function is removed from the execution stack and whatever data that was inside is forever lost.

The same thing happens to jump and then cat. The global execution context will always remain until the program is terminated for some reason, such as a page refresh.

Hoisting Ain’t That Fancy

There’s a lot of material about hoisting out in the Interweb wild. It’s often littered with phrases like ‘bubbling to the top’ or ‘floating up’ or some kind of description that covers the act of variables inside scopes ‘coming out’ from where they sit to permanently modify the variable.

In truth, nothing physically moves in the code. Hoisting, rather, is the act of assigning the topmost variable after it’s been declared.

var cat = "Tibbers";

let kitten = "Mittens";

Despite being only one level, In the declaration above, cat is hoisted with the value Tibbers at the execution phase because cat was originally declared and set with the default undefined in the creation phase.

When it comes to let, hoisting — aka, the act of reassigning values — gets a little trickier because a variable’s continued existence is limited to the block scope only. This is because let is its own object within an execution context and does not extend itself outside its {curly brackets}.

var, on the other hand, attaches itself to the window object and is, therefore, easier to persist than let because var is limited by the nearest function scope rather than block scope.

Hoisting Example

Take a look at the code below. There are three functions in total and each time a function is called, the global declared variable is observed through console.log:

var cat = "Tibbers";
let kitten = "Mittens";

function hoistMe(){
  cat = "Ibbers";
  kitten = "Ittens";
}

function hoistMeAgain(){
  cat = "Bbers";
  kitten = "Ttens";
}

function finalHoist(){
  var cat = "Bers";
  let kitten = "Tens";
  bear = "Billy";
}

console.log(cat, kitten); //Tibbers, Mittens
hoistMe();
console.log(cat, kitten); //Ibbers, Ittens
hoistMeAgain();
console.log(cat, kitten); //Bbers, Ttens
finalHoist();
console.log(cat, kitten, bear); //Bbers, Ttens, Billy

With the above code, the JavaScript interpreter runs through the following process to arrive at the final variable that’s been hoisted:

  • At the very beginning, a global execution context is created and the creation phase begins. window, this , cat, hoistMe(), hoistMeAgain(), and finalHoist() is created. cat is assigned with undefined.
  • The first hoist occurs at the execution phase, which follows immediately after the creation phase. This first hoist happens at cat when it is reassigned from undefined to Tibbers. During this time, kitten is created as an object and is assigned with the value Mittens. This is why the first console.log in the code will return Tibbers and Mittens.
  • The function hoistMe() is invoked and a new execution context is created. Memory space is allocated to any new variables declared. In our case, there is none to be made during the creation phase. New this and arguments objects are created. The execution phase follows and references cat and kitten to the nearest declared matching variable. Since there are none within the immediately {curly brackets}, the JavaScript interpreter checks the execution context that invoked the current hoistMe context.
  • Luckily for us, cat and kitten are declared in the invoking context, thus the JavaScript interpreter is able to hoist (i.e. reassign) the values to Ibbers and Ittens .
  • Once hoistMe finishes executing, memory is released and the permanency of the hoisted values is experienced through console.log that follows.
  • hoistMeAgain() is invoked and the process is repeated, resulting in console.log outputting Bbers and Ttens.
  • finalHoist() runs but has a different impact because cat and kitten is being declared rather than referenced. In addition to this, bear makes an appearance and it hasn’t been previously declared anywhere else in the code. In this situation, the creation context makes memory space for an object called cat specific to finalHoist execution context only. kitten is created at the execution phase as its own object.

Although bear makes its first appearance in finalHoist(), the JavaScript interpreter uses the same method of working its way through the invoking execution context to find a matching object name. When none exists, it creates one at the global level.

As a result, when we console.log it in the invoking execution context, we end up with the values Bbers , Ttens and Billy. The values for cat and kitten are not hoisted because of how new objects were created and therefore the JavaScript interpreter doesn’t go looking, despite having the same names.

If you try and console.log(bear) before running finalHoist(), a big red error will appear and tell you off for not declaring it. To the interpreter, bear simply doesn’t exist yet. Once you invokefinalHoist(), you bring in the global scope a variable that’s been implicitly declared inside a function.

Final Words

Bugs in JavaScript often occurs when developers dive right into coding without understanding the execution context.

While visually, the code may look clean, misunderstanding how the linear execution processes occur in JavaScript can result in head-scratching moments.

When JavaScript doesn’t work, it’s not because it’s weird, rather it is because it is being misunderstood. Cats, kittens and bears aside, I hope this helped clarify why JavaScript works the way it does.