How JavaScript Really Works Under the Hood

Synchronous Javascript (Javascript Engine)
Javascript is a synchronous language at its core, that means it runs on a single thread with a single call stack. So, all the code you write in core JS is executed line by line, one after the other on the same call stack. It doesn’t skip to the next instruction until previous one is completed.
Execution Context
Execution context is an environment that Javascript uses to run the code. It starts off with a global execution context, and creates a new one for each function call. So, you can think of it as a separate environment for each function call that holds the space for variables used inside it.
Javascript creates a stack frame for each execution context in the call stack. To start with, a global execution context stack frame is added with all the global variables, including the global object - window, in case of browser and global, in case of Node environment. Then with each function call, a separate execution context is created and stacked at the top of the call stack. As soon as function completes its execution, its stack frame is popped out of the stack. Global execution context always resides in the stack at the bottom until the program is running.
Let’s take an example to understand the concept -
Asynchronous Javascript (Javascript Engine + Browser/Node API)
When core Javascript is combined with Browser or Node APIs, its synchronous nature tends to shift to asynchronicity. Functions like setTimeout, fetch, etc. doesn’t execute instantly. So, the result for these operations is recieved after a while. Now, Javascript has 2 options whether to wait for the operation to complete(synchronous) or skip to the next one as the previous one keeps executing(asynchronous). Javascript goes with the asynchronous approach, but how does this get implemented? Do these operations run on separate threads or on the same with context switching? Who manages this asynchronous nature of JS?
Let’s look at these questions in detail.
First, lets look at some termonolgy required to understand the concepts:
Promise: This is an object which eventually evaluates to success or failure but not at the moment. The operations like fetch which can’t complete instantly, returns a promise to complete. So, rather than returning a response, they return promise object. Think of it as a placeholder object, which when completed, gets replaced by the success or failure response.
Callback: These are the functions which needs to be executed after these asynchronous operations complete.
Now, lets understand the components which really makes JS async:
Call Stack: This is the same stack we discussed above which take the function call one by one and executes them. So, Javascript has single thread, single call stack that runs everything.
Web/Node APIs: These APIs are what makes JS asynchronous. They take some time to execute making JS switch to the next operation. Eg: setTimeout, fetch(Browser), file system(Node), etc.
Task Queue: This a queue which holds all the callbacks.
Event Loop: It checks the task queues and call stack constantly and pushes the callbacks to stack once its empty.
Microtask Queue: This is also a task queue which holds callbacks but has higher priority. Callbacks for promises are pushed in it and gets executed before the task queue.
Now, let’s look at the actual flow:
Javascript runs as usual one operation after the other, all operations completing almost instantly.
It encounters fetch, which is part of Browser API and will take some time to complete.
It delegates this request to the Browser and moves to the next operation.
When Browser executes the request and fetch operation completes, the callback function along with the response is pushed into the queue.
Event loop checks the stack and queue regularly and pushes the callback in stack as soon as stack gets empty.
The call stack executes the callback.
This way, Javascript along with the environment(Browser/Node) including task queues, event loops,etc. handles asynchronous tasks using a single call stack.
Following code demonstrate the complete flow:
async function f() {
console.log("Starting async function...");
setTimeout(() => {
console.log("Async operation complete..."); // Callback function body
}, 0);
console.log("Async function complete...");
}
console.log("Starting execution...");
f();
console.log("Execution complete...");
/* Output:
Starting execution...
Starting async function...
Async function complete...
Execution complete...
Async operation complete...
*/
Microtask queue always gets precedence over task queue:
async function f() {
console.log("Starting async function...");
setTimeout(() => {
console.log("setTimeout complete...");
}, 0);
Promise.resolve().then(() => {
console.log("Promise complete...");
})
console.log("Async function complete...");
}
console.log("Starting execution...");
f();
console.log("Execution complete...");
/* Output:
Starting execution...
Starting async function...
Async function complete...
Execution complete...
Promise complete...
setTimeout complete...
*/
Subscribe to my newsletter
Read articles from Payal Rathee directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
