If you are someone who has heard about the terms event loop, callback queue, concurrency model and call stack but doesn't really understand what they actually mean, this post is for you. Having said that, if you're an experienced developer, this post might help you to understand the internal working of the language and enable you to write more performant user interfaces.
JavaScript as a language has grown exponentially over the past decade and has expanded its reach on various levels of developer stack i.e. frontend, backend, hybrid apps etc. Gone are those days when we used to talk about JavaScript in the context of browsers only. Despite its popularity and growing demand, very few developers actually understand how the language works internally. This post is an attempt to clarify and highlight how JavaScript works and what makes it weird when compared to languages that you might have previously used.
Overview
Unlike languages like C++ or Ruby, JavaScript is a single threaded language. That means it can do only one thing at a time and while it is doing that it cannot do anything else. Although it has its own benefits as you don't have to deal with the complexity that comes with multi-threaded languages like deadlocks, it also means that you cannot do complex operations like image manipulations or any other heavy process because the browser will stop everything else in order to perform that operation.
The JavaScript Runtime
You probably have heard of V8, the JavaScript engine that powers Chrome. A JS engine consists of two main components - Heap and a Call Stack. The Heap is where all the memory allocations (and deallocation) take place. The Call Stack is basically a data structure that records where we are in a program. That means if there is an execution context present in the program, it pushes it to the stack and pops the context when it encounters a return. In most scenarios, the execution context is just a function call. To make this more clear, let's go through this simple program and visualize how it gets executed inside the Call Stack.
return a*b;
}
function square(a) {
const sq = multiply(a, a);
console.log(sq);
}
square(3);
The JS engine will initialize memory for the function declarations in the heap. When it reaches line 11, it will encounter a function execution and the JS runtime will push it into the Call Stack. The following steps demonstrate the state of call stack at each step -
Each entry in the Call Stack is called a stack frame. You might have already seen it when the browsers prints a stack trace on the occurrence of an error.
throw new Error("Print the stack trace from here!!");
}
function foo() {
customError();
}
function bar() {
foo();
}
bar();
Assuming the file is named main.js, the following stack trace will be displayed in the console
Let's say we have a recursive function that calls itself infinite times. For example -
bar();
}
bar();
On execution, the function bar will get added to the call stack on each call until the call stack finally runs out of memory and throws an out of range error. This is infamously known as blowing the stack.
Let's talk async
Before we really dig into async code, we'll see how blocking or synchronous code effects our Call Stack. Take a look at the jQuery code below for example
const resposne1 = $.get('https://example.com/api/data1', (res) => {
return res;
});
const resposne2 = $.get('https://example.com/api/data2', (res) => {
return res;
});
const resposne3 = $.get('https://example.com/api/data3', (res) => {
return res;
});
console.log(resposne1);
console.log(resposne2);
console.log(resposne3);
The first line basically sets all the ajax requests to be synchronous. Thus, while we're are awaiting the response from the ajax requests, the Call Stack is blocked and our program will remain unresponsive for that time. If you would simulate the same behavior of the above code in a browser, all the other elements of the page will go into a blocked state i.e. you can not interact with them until they exit the blocked state. This is the reason why it is a bad practice to perform synchronous network requests or any other operation that requires large computation time.
The solution to this is pretty straightforward - asynchronous callbacks. You probably have already used asynchronous code in your program. APIs like setTimeout or xhr requests are asynchronous. But before we explore how they actually work, let us visualize how the Call Stack appears when it executes async code.
On execution ,when the program encounters a setTimeout, it queues it to be executed after a certain interval and then moves on to the next line. After the stack is empty (and the timeout is finished) the setTimeout callback magically gets pushed into the stack and is executed. We will see how this happens in the next section.
Concurrency model and the Event Loop
You might think that how concurrent processes can be executed given that JavaScript is single threaded. Although this is true that JavaScript runtime can do only one thing at a time, the browser in itself is a lot more than just the runtime. The browser consists of other things like Web Apis, a Callback Queue, and an Event Loop. The Web Apis are threads that you can request to and have them perform any process while keeping the Call Stack clear.
This is how the complete JavaScript environment looks like. In the context of node, the above picture remains same except that instead of Web Apis you've C++ APIs like threads, file system etc. Let's get back to the code we executed in the previous section and see how it fits in the bigger picture.
Apis like setTimeout, xhr etc are not present in the runtime but are rather provided by the Web Apis. When you call the setTimout function, it registers a timer function along with the callback. When the timer expires, it sends the callback to the event queue which is then pushed to the Call Stack by the Event Loop when the Call Stack is empty.
The Event Loop has a single job - it watches the Call Stack and the Callback Queue. When the Call Stack is empty, it takes the first event in the queue and pushes it to the stack which effectively runs it. Such an iteration is called a tick in the Event Loop. Each event is just a function callback.
A thing about setTimeout(..)
One of my earliest encounters with async code was this -
setTimeout(() => {
console.log("I'll execute only when stack is empty :(");
}, 0);
console.log("I'll execute second");
It initially seems that the timeout callback will execute almost immediately. However, it is important to note that the timer itself doesn't put the callback into the Callback Queue. When the timer expires, the environment puts the callback to the queue which is pushed to Call Stack when it's empty.
Thus, setTimeout doesn't make your callback to run after a specified time. Rather it just ensures the minimum time after which the callback executes. With a timeout of 0 seconds, the timer expires almost immediately and the callback is placed in the Callback Queue. But the Event Loop still has to wait until the stack is empty before it can push the callback in it. This means that we basically defer the execution of the callback until the stack is clear.
Conclusion
Understanding the environment in which your program runs can significantly increase your efficiency and effectiveness as a developer. It highlights the logic as to why a given program works the way it does. If you felt there was something missing or if you like the post, do let me know in the comments. You can also follow me on Github or Twitter where I share insights on programming and developement in general.
Over and out!
Leave a Reply
Your email address will not be published. Required fields are marked *