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()
, andfinalHoist()
is created.cat
is assigned withundefined
. - 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 fromundefined
toTibbers.
During this time,kitten
is created as an object and is assigned with the valueMittens
. This is why the firstconsole.log
in the code will returnTibbers
andMittens.
- 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. Newthis
andarguments
objects are created. The execution phase follows and referencescat
andkitten
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 currenthoistMe
context. - Luckily for us,
cat
andkitten
are declared in the invoking context, thus the JavaScript interpreter is able to hoist (i.e. reassign) the values toIbbers
andIttens
. - Once
hoistMe
finishes executing, memory is released and the permanency of the hoisted values is experienced throughconsole.log
that follows. hoistMeAgain()
is invoked and the process is repeated, resulting inconsole.log
outputtingBbers
andTtens.
finalHoist()
runs but has a different impact becausecat
andkitten
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 calledcat
specific tofinalHoist
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.