How JavaScript works??? ๐Ÿค”๐Ÿ’ญ

How JavaScript works??? ๐Ÿค”๐Ÿ’ญ

ยท

25 min read

When I started my journey with JavaScript I used to think about how things work under the hood, why the setTimeout code runs after the synchronous code, how is it even possible to call a function even before declaring it, and why do we get errors if we try to use variables created using let and const before initializing them and why don't we get an error if we do the same but with a variable defined with the var keyword. After learning from multiple resources about how things work internally all my doubts were clear and hope this article will give you a clear understanding of how JavaScript works internally.

Before deep diving into the main topic let's briefly understand something about JavaScript.

What is JavaScript?

You probably know that JavaScript is a single-threaded client-side scripting language that is used to give logic to a webpage besides that JavaScript can also be run on the server side using NodeJs (a runtime environment for JavaScript). Now you must be thinking is JavaScript an object-oriented language or a functional language? The answer is JavaScript is not an object-oriented language neither it is completely a functional language. It is a prototypal-procedural language meaning it supports both functionality of object-oriented and functional programming. Now let's dive deep into the main topic.

What happens before the code is executed?

"Everything in JavaScript happens inside an Execution Context". This is the most important thing to always remember and you'll know why, later.

As we know JS is an interpreted language so it is not required to compile the code into binary code before executing. But in EcmaScript's official documentation, it is mentioned that before executing the code it is required to have an early error checking and for early error checking some steps are performed.

Note - Modern JavaScript engine uses a mix of interpretation and compilation known as JIT compilation (Just-In-Time).

Early Error Checking steps

  1. Tokenizing - Tokenizing is the process of breaking the code into smaller pieces known as tokens. These series of tokens represent the fundamental building blocks of the program such as identifiers, keywords, operators and punctuation. The Tokenizer or Lexer is responsible for scanning the source code and dividing it into small meaningful tokens.

  2. Parsing - After the tokenizing is done the parser takes the sequence of tokens and checks for any syntax errors. If the parser encounters a series of tokens that do not match the language's grammar rules, it then reports a syntax error and stops the code execution. If the parser does not find any error then it takes the sequence of tokens and creates an Abstract Syntax Tree (AST).

  3. Abstract Syntax Tree (AST) - An AST is a tree-like data structure that represents the syntactic structure of the program in a more abstract and structured way. After the AST is ready the JavaScript engine then converts it into machine code and then runs it. Click here to see how AST is created.

Some important terminologies.

Let's first understand some terminologies before jumping into the code execution topic this will help later.

  1. JavaScript Engine - The JS Engine is a program that runs JavaScript code. JS Engine runs each line of code sequentially as soon as it is loaded in the browser. Almost every browser has its own JS Engine for example Chrome has JS Engine named V8 engine, Microsoft Edge has Chakra, Firefox has Spider-Monkey, etc... The JS Engine has 2 main components:- 1. Call Stack, 2. Heap. The JS Engine does not run in isolation it runs inside a JavaScript Runtime Environment. We can understand the JavaScript Runtime Environment (JRE) as a big container that contains components like JS Engine, Web APIs, Queues and Event Loop. With the help of these components, we can achieve asynchronous behavior in JavaScript and can also make HTTP requests.

    • Call Stack - The call stack functions as the stack data structure which follows the LIFO (Last In First Out) principle. Each time a function is invoked or a program starts executing they get pushed inside the call stack and after successfully executing they are popped out of the call stack. The call stack maintains a record of currently executing code.

    • Heap - The heap is a memory area used for dynamic data storage, unlike call stack which has limited memory space. It allows for flexible memory allocation and deallocation, making it suitable for complex data structures like objects and arrays. In JS arrays and objects are stored in heap memory while pointers to these objects and arrays are stored in the stack.

  2. Web APIs - These are the APIs provided by the browser, such as DOM API, timeout and interval API and some others. With the help of these Browser APIs, we can easily make our JS code asynchronous. Let's see a very short DOM API example

     <!DOCTYPE html>
     <html>
     <head>
         <title>How JS works?</title>
     </head>
         <body>
             <button id = "btn">Click me</button>
         </body>
     </html>
    
     console.log("Hello world");
     const btn = document.getElementById("btn");
     btn.addEventListener("click" , () => {
         console.log("button clicked")
         alert("You clicked on the button");
     });
     console.log("Bye world");
    

    In the above example, we have a button with id "btn" and in the script file we have a console.log statement that says "Hello World". Then we have selected the button using the DOM API and we have attached an event to the button. Now when we execute this code it will just print Hello world and Bye world. We have given the browser a responsibility that whenever a user clicks on the button just give us back the callback function that we have passed while adding the listener to the button and then that callback function will get inside a queue called Callback queue or Message queue then it'll go inside the call stack and we'll be able to see the output. (We'll learn all these things in more detail in a while).

  3. Callback Queue and Microtask Queue - When an event occurs in JS like click event, mousemove event, mousedown event etc... or a timeout is executed, then the function that we have passed as a callback, goes inside the callback queue and when the call stack gets empty event loop pushes that function inside the call stack and then the function gets executed. When we have promises in our code the callback of the promise function goes inside of the Microtask queue and then the rest procedure is the same as the callback queue. But there is a catch that the Microtask queue's priority is higher than the callback queue's priority meaning that if there are callback functions inside both the queue then event loop will take the callback of the Microtask queue first and then put it inside the call stack and when it gets executed then it'll put Callback queue's callback inside call stack.

  4. Event Loop - The event loop is responsible for taking the callbacks from the queues and putting them inside the call stack for execution. The event loop continuously checks the call stack for any pending tasks and when the call stack is empty it looks into the microtask queue and callback queue for any pending functions. It prioritizes the microtask queue over the callback queue, ensuring that promise callbacks are processed and executed without any delay.

How the code is executed?

When we execute our code the JS Engine creates an execution context. It is the place where all the JS code is evaluated and executed. Initially, an execution context called Global Execution Context is created and after that whenever a function is invoked the JS engine creates a new execution context for that function and that is called a Function Execution Context. Let's see the phases of an execution context.

  • Creation Phase (Global Memory) - In this phase, JS Engine sets up the execution context that includes creating the lexical environment (Variable Object) to manage variables and functions within that context. The JS Engine also creates the scope chain that defines how variables and functions are accessed hierarchically. And the most important thing is the value of this is determined within this phase. The variables and functions are stored in key-value pair form inside the global memory.

A variable object is a container that holds information about the variables and functions declared within a particular context.

The variable object also participates in creating the scope chain. The scope chain is a way to look up variables and functions in their hierarchical order within a nested execution context.

  • Execution Phase - In this phase the JS Engine executes the actual code line by line assigning values of the variables and executing functions (that are made during the creation phase).

Let's understand these things with an example

Example 1 :-

var message = "Hello visitor ๐Ÿ‘‹๐Ÿป๐Ÿ‘‹๐Ÿป"; // line 1
console.log(message); // line 2

When this code runs, the following will happen :-

  1. A global execution context will be created.

  2. Now we know that this execution context will have 2 phases. The first is the creation phase which is also referred to as Global memory. And inside that value of this will be determined in this case it'll be the global/window object. After that, the variable message will be created and will be set equal to undefined.

  3. Now comes the execution phase in this phase the above code will run line by line. The first line will initialize the variable message with the "Hello visitor ๐Ÿ‘‹๐Ÿป๐Ÿ‘‹๐Ÿป" string and the second line will print the message on the console.

  4. After the code is executed the Global Execution Context will pop out from the call stack.

Output :-

As soon as the program starts, Global Execution Context will automatically get pushed onto the call stack by the JS Engine.

Example 2 :-

This example is going to be quite similar to our previous example.

console.log(message); // line 1
console.log(this); // line 2
var message = "Hello Visitor ๐Ÿ‘‹๐Ÿป๐Ÿ‘‹๐Ÿป"; // line 3
console.log(message); // line 4

When we run this code :-

  1. Global Execution Context will be created and in the Global Memory phase, the value of this will be set to window/global object (but if we are using Nodejs the global object will be different). After that message will be set equal to undefined.

  2. Here comes the interesting part, when JS Engine runs the code line by line we'll see that the first line will print undefined in the console this is because of hoisting (we'll cover hoisting in some other article). After that, it will print the global object because this was set equal to the window object, and then message will store "Hello Visitor ๐Ÿ‘‹๐Ÿป๐Ÿ‘‹๐Ÿป" in it and finally the last line will print "Hello Visitor ๐Ÿ‘‹๐Ÿป๐Ÿ‘‹๐Ÿป" in the console.

Output :-

Let's now slightly increase the difficulty of our examples

Example 3 :-

console.log(user1); // line 1
// console.log(user2); // line 2
var user1 = "John Doe"; // line 3
let user2 = "Jane Doe"; // line 4

console.log(`user1 is ${user1} and user2 is ${user2}`); // line 5

sayName(); // accessing before defining // line 6
function sayName () { // line 7
    console.log("inside sayName function");
}

// sayName2(); // line 8
console.log(sayName2); // line 9
var sayName2 = function () { // line 10
    console.log("inside sayName2 function");
}

sayName2(); // line 11

Running this code :-

During the compilation phase (when the JS Engine parses the code) the JS Engine scans and analyzes the code to identify and record the variables and functions declared in the global scope, as well as those defined in any other function or block scope within the code. This information is then used later for creating the variable object (global memory) for each execution context.

  1. On running the code global execution context will be created, and in the global memory value of this will be set equal to the window object, user1 will be undefined, user2 will be uninitialized and will go into the Temporal dead zone (TDZ), sayName function will be as it is in the global memory because it was created using the function keyword (function declaration), sayName2 will be undefined.

  2. Let's jump to the code execution phase:-

    • Line 1 will print undefined because in the global memory, it was initially set to undefined.

    • Uncommenting line 2 will give you a Reference Error because when we use let or const to define a variable/constant or a function it is initially set to uninitialized inside the global memory. So, accessing a variable that currently is not initialized will give us Reference Error (try uncommenting by yourself and check the result).

    • Line 3 will assign "John Doe" to user1 variable and line 4 will assign "Jane Doe" to user2 variable (now it is initialized).

    • Line 5 will simply print user1 is John Doe and user2 is Jane Doe in the console.

    • Line 6 will call the function and surprisingly we will be able to see the output in the console, this is because we have created this function using the function keyword so, when this function is hoisted to the top of its respective scope it will present in the global scope as it is.

    • Line 7 will do nothing, just the function is defined there.

    • If you uncomment line 8 you'll see an error saying sayName2 is not a function which is quite obvious because this time we have assigned this function to a variable that is made using the var keyword so initially it will be undefined(try uncommenting it).

    • Line 9 will print undefined and now you know why. On line number 10 the function will be assigned to the variable sayName2. Now we can use this function.

    • Line 11 will print, inside sayName2 function.

  3. A short note :- In the above example wherever we have invoked any function a new execution context will be created I haven't shown that in this example because there was just a simple console.log statement inside all the functions. But we'll see about that in our next example.

Output :-

Before proceeding to our next example let's briefly understand about 1) Temporal Dead Zone (TDZ). 2) Why don't we get undefined if we log a variable that is made using let or const before initializing it? 3) How are we able to access a function before declaring it and why can't we use it if it is a function expression.

  1. Temporal Dead Zone (TDZ) - TDZ refers to the period between the hoisting of a variable or constant created using let or const and actual declaration and assignment of a value of that variable. During this period we cannot access our variables or constants and if we try to do so we'll get Reference Error.

  2. Not getting undefined in case ofletandconst - Variables that are created using the var keyword are hoisted and initialized with undefined it is because it is a part of the language's design and execution process. Variables or constants that are created using let and const keywords are also hoisted but unlike var they're not initialized automatically with undefined. If we try to access them before initializing them we'll get reference error because they remain in TDZ until they're assigned a value. z

  3. What's the matter with functions - When we define a function using function declaration it is hoisted to the top and gets in the global scope as it is, yes the whole code of the function goes inside the global scope that's why we can invoke a function even before declaring it. But when we have any function expression or an arrow function that we have created using var , let or const the behavior is something different. When creating a function expression or an arrow function it is treated as a variable or a constant (if created using const) so if it is created using var then it'll be undefined initially and if it is created using let or const then it will be uninitialized. Now let's get back to our examples.

Example 4 :-

let products = [ //line 1
    {
        productName: "MacBook",
        price: 2399,
        stock: 20,
        brand: "apple"
    },
    {
        productName: "Galaxy S23 Ultra",
        price: 1199,
        stock: 34,
        brand: "samsung"
    }
]
const calculateFinalAmount = (product) => { // line 2
    console.log("product details" , product);
    let finalPrice = 0;
    let taxAmount = 0;
    const taxPercent = 6;
    const amountBeforeTax = calculateDiscount(product);

    taxAmount = (amountBeforeTax / 100) * taxPercent;
    finalPrice = amountBeforeTax + taxAmount;
    return finalPrice;
}
function calculateDiscount (product) { //line 3
    let discountedPrice = 0;
    let discount = 0;
    const { price, brand } = product;
    const discountPercent = brand === "apple" ? 13 : 18;

    discount = (price / 100) * discountPercent;
    discountedPrice = price - discount;

    return discountedPrice;
}
const finalBill = calculateFinalAmount(products[0]).toFixed(2); // line 4
console.log(`Your total is $${finalBill}`); // line 5

First, let's just understand what this code does. Here you can see that we have an array that contains 2 objects that have some product information in them and we have 2 functions. The function calculateDiscount takes a product object and subtracts the discount percentage which is different for each brand from the product price and then returns discountedPrice, this calculateDiscount is being used in calculateFinalAmount function and this function just adds the tax percentage to the discountedPrice and then returns the finalPrice to the user.

Let's run the code :-

Remember that whenever a new execution context is created it will have 2 phases 1. Creation phase, 2. Execution phase.

  1. As usual, we run our code the JS Engine will create a global execution context, and in the global memory (creation phase)this will be set equal to the window object, the variable products will be set equal to uninitialized and will remain in the temporal dead zone (TDZ), and the calculateFinalPrice will also be uninitialized (it's an arrow function and is stored in a constant that's why it is uninitialized in the global memory) calculateFinalPrice will also remain in TDZ. The function calcuateDiscount will be present as it is in the global memory because it's a function declaration. The constant finalBill will also be uninitialized and will remain in TDZ.

  2. Execution Phase - Let's run the code line by line

    • Line 1 will initialize the products variable with an array of objects.

    • Line 2 will initialize calculateFinalAmout constant with an arrow function.

    • Line 3 will do nothing because the JS Engine already knows that there is a function named calculateDiscount present in our code (JS knows this information because of the compilation phase).

    • Line 4 is invoking the calculateFinalAmount function and storing the returned value inside the finalBill constant. Now as soon as the function is invoked a new execution context will be created called function execution context, and this function execution context will also have 1. Creation phase (local memory in this case) and 2. Execution phase. And when this function was invoked it was pushed inside the call stack to keep track of this function execution. Now all the steps are the same. In the local memory creation phase variable finalPrice, taxAmount, taxPercent and amountBeforeTax will initially be uninitialized and remain in TDZ, one more thing when we have functions we have an array-like object inside them that stores the parameters that we pass while invoking the function. Here comes the execution phase, line 1 will simply print product details {... products details (that we have passed while calling the function)}. Line 2 will initialize finalPrice variable with 0, line 3 will initialize taxAmout with 0, line 4 will initialize taxPercent with 6 (all these variables are now out of the TDZ). Now, when the 5th line runs (we are still inside the calculateFinalAmount function) a new function execution context will be created because we are calling calculateDiscount function in this line and passing the product object that we have received in calculateFinalAmount function and this new function execution context will be pushed inside the call stack. See, we have 2 function execution contexts now, and we're in the 2nd one at the current moment. I'll call the firstfunction execution contextas amount execution context and the 2nd one as discount execution context just for our understanding. Currently, we are inside of the discount execution context and it was also pushed onto the call stack when the function was invoked, for this execution context too we'll have a creation as well as an execution phase. In the local memory, we'll havediscountedPrice, discount, price, brand, discountPercent set to uninitialized. Jumping to the execution phase, line 1 will set discountedPrice to 0, line 2 will set discount to 0, line 3 will destructure price and brand from the product object that has passed from the calculateFinalPrice function. So, we'll price will be initialized with 2399 and brand will initialize with "apple", in line 4 we are evaluating the value of discountPercent using the ternary operator so 13 will be stored in it. Line number 5 and 6 are just calculating the discount and discountedPrice and whatever the result would be it will save in the respective variable. On the last line, we're returning the discountedPrice.

    • Finally the work of calculateDiscount function is finished and the discount execution context will be popped out of the call stack, we now only have the amount execution context and global execution context present inside of the call stack. The value that was returned by the calculateDiscount function will now be stored inside the amountBeforeTax variable (that is on the line number 5 of calculateFinalAmount function) moving forward with the calculateFinalAmount, line number 6 will calculate the tax amount and will store it in the taxAmount variable line 7 will calculate the finalPrice and the next line will return the finalPrice. This function is also executed successfully now it'll pop out of the call stack.

    • We are now left with global execution context in the call stack and on line number 4 we're storing the returned value from the calculateFinalAmount function.

    • Finally, line number 5 will print the Your total is $2212.36 in the console.

    • You can add some more products, modify the function logic according to yourself and try executing the function with different products.

Output :-

Example 5 :-

console.log("program started"); // line 1
console.log(time); // line 2
var time = 1000; // line 3
const number = 3; // line 4

const timeoutCallback = () => { // line 5
    console.log("Timeout executed")
}

setTimeout(timeoutCallback, time); // line 6

count(number); // line 7
function count (num) { // line 8
    for(let i = 1; i <= num; i++){
        console.log("count is" , i);
    }
}
console.log("program ended"); // line 9

Let's run the code :-

  1. A global execution context will be created and in the creation phasethis will be set to the window object, the variable time will be set to undefined, the constant number and timeoutCallback will be uninitialized. The function count will be the same in the global memory. *Because of the compilation phase the JS Engine knows about the variable inside the countfunction *.

  2. Let's see the execution phase line by line :-

    • Line 1 will print the program started.

    • Line 2 will print undefined because, in the global memory, it is initialized as undefined.

    • Line 3 will initialize time variable with 1000.

    • Line 4 will initialize number constant with 3.

    • Line 5 will initialize timeoutCallback constant with an arrow function.

    • Now comes the tricky part, on line number 6 we have called a setTimeout function and this function takes a callback and then executes it after some time interval. Now, some people might think that okay so the callback function will execute after 1000ms and we'll see Timeout executed in the console (we have passed time variable which is 1000 as the second argument that specifies that after how many milliseconds this callback should run). But that's not the case, the setTimeout will register a callback in the web API's environment and a timer of 1000ms will start and as soon as the time is up the callback function will go inside of the callback queue (we already have discussed about the callback queue) and when the whole synchronous code is executed then this callback will run. The event loop will check the call stack if the call stack is empty then only it will push that callback function from the callback queue.

    • Line 7 will call the count function and pass number constant as the argument. And as soon as the function is invoked a function execution context will be created. Again the function execution context will be created (and will be pushed in the call stack) in 2 parts first is the local memory creation phase and the second one is the execution phase. In the local memory creation phase we'll have an array-like object that has all the parameters that are passed as an argument while invoking the function and with that, we have a variable i initialized with uninitialized and will remain in TDZ. Moving to the execution phase -> line 1 will initialize the variable i with 1 and then the condition will be checked if it is true then it will go inside of the loop and will print count is 1 this will continue until the condition becomes false. After the function has been executed successfully its execution context will be popped out of the call stack.

    • Line 8 is already completed

    • Line 9 will simply print program ended in the console and the global execution will be popped from the call stack.

    • After the call stack is empty the event loop will take the callback present inside the callback queue and will put it inside the call stack for execution. Now we can see Timeout executed written in the console.

Output :-

Example 6 :- This is going to be our last example

const url = "https://jsonplaceholder.typicode.com/users"; //line 1
console.log("script start"); // line 2

fetch(url).then(function cb (response) { // line 3
    console.log(response);
}).catch((error) => {
    console.log("Error while fetching")
})

function timeoutCallback () { // line 4
    console.log("timeout executed");
}

setTimeout(timeoutCallback, 50); // line 5

for(let i = 1; i <= 100; i++){ // line 6
    console.log("i is" , i);
}

console.log("script end"); // line 7

In the above code snippet, we are fetching users from an API and with that we have a timeout also and we're running a loop from 1 to 100 and we have some log statements also.

Let's run this final example :-

  1. As always when we run the code the JS Engine will create a global execution context and in the first phase which is the global memory phase or creation phase this will be set to the window object, url constant will be uninitialized, and the variable i will also be uninitialized but the timeoutCallback will be present in the global memory.

  2. Execution phase :-

    • The first line will initialize url constant with the API.

    • The second line is printing script start.

    • In line 3 we have used fetch API which is a web API provided by the browser, so the callback inside of the .then method will register in the web API's environment and when the fetching is done it'll get inside of the microtask queue.

    • Line 4 is already completed (already present inside the global memory), and line 5 is also using a web API (the timer API) so the callback that is passed inside the setTimeout will register in the web API's environment and the timer will start and after the timer is completed it will go in the callback queue.

    • 6th line will initialize i variable with 1 and the loop will run 100 times and for each iteration, it'll print i is and then the current value of i.

    • The last line will print script end.

    • We're still left with our timeout and the fetch. I have told you a thing that the microtask queue has higher priority than the callback queue which is true only if both the queues have callback in them at the same time, in this case event loop will give more priority to the callbacks present in the microtask queue. But if the promise is taking more time to resolve and there is already a callback present inside the callback queue then the event loop will simply put it inside the call stack that's why in this example you might sometimes see timeout executed text before the API response. The event loop will push the callbacks one by one and as soon as the callback is executed successfully it'll be popped out from the call stack.

    • Try playing with the timer values and see the change.

Output :-

For the output purpose I've reduced the number of times the loop will run. So that I can fit the full output in a single screenshot.

Summary

Let's quickly summarize what we have done so far.

  • JavaScript is a single-threaded prototypal-procedural language that supports both functional as well as object-oriented programming.

  • Before code execution, the code is broken down into a sequence of tokens then this sequence of tokens is checked for any type of syntax error by the parser if everything is fine AST is created. Then the JS Engine converts the AST into machine code and executes it.

  • The JS Engine is responsible for running the code line-by-line it has two main components 1. Call stack, 2. Heap. The call stack keeps track of the current executing code. A heap is a memory area that is used for dynamic storage.

  • The JS Engine doesn't run in isolation it runs inside a JS runtime environment. The JRE is like a container that contains the JS Engine, Web APIs, Queues and Event Loop. 1. Web APIs are provided by the browser like the DOM API, timeout and interval API, fetch API and more. 2. Queues are of two types -> Microtask queue which stores the promise callback and Callback queue which stores callbacks of DOM API or timer APIs. 3. Event loop helps in the proper execution of synchronous code by preventing the queue's callback from entering inside the call stack.

  • When we run our code JS Engine creates an execution context, and this execution context is further created in 2 phases. 1) Creation phase -> In this phase variable object is created. The variable object contains the variables and function declaration of the current context. 2) Execution phase -> In this phase the JS Engine runs the code line by line.

  • When we talked about the parsing and creation of AST we can call that compilation phase and when we are creating execution context we can call that code execution phase.

  • During the compilation phase, the JS Engine analyzes our code and records the variable and function declaration in the global scope, it also records the local variables and function. This information is used later in the creation phase for each execution context.

  • When we try to access variables before declaring them we get undefined in the case of var and uninitialized in the case of let and const this is because of hoisting. When a variable is created using let or const it remains in TDZ. TDZ is the period variable declaration and initialization. When we have a function declaration we can invoke it before declaring it, but if it's a function expression or an arrow function then we can't invoke it before initializing because it is evaluated as a variable or a constant.

Thank you reading my blog ๐Ÿš€, I hope that all of your doubts are cleared now and if not please feel free to ask them in the comment section I'll surely reply to your queries. And if you have any feedback for me please write that too in the comment box. ๐Ÿ˜

ย