Introduction
Fundamentally, JavaScript is an event-driven programming language, operating on a single thread and offloading time-consuming operations to be carried out disparately. This is perhaps because it runs on web browsers where other useful activity is desired besides just processing and executing JavaScript.
For instance, rendering the HTML and CSS is another, pretty challenging, concern. And so is tracking user interactions and reacting to them. Let's also not forget about networking operations performed by browsers.
Whatever the case be, the point is that it suits JavaScript to be build around a concurrency model whereby the main thread is made free quickly enough to carry out other useful activity while background operations continue and only enter the game when they need to. All this near-magic functionality is the outcome of a crucial feature in JavaScript — the event loop.
In this chapter, we shall understand what exactly is the event loop; what is its role in running JavaScript; what is synchronous vs. asynchronous code; how the event loop works internally; and a lot more on this road.
Without any doubts whatsoever, it's more than just necessary for every single JavaScript developer to be aware of the basics of the event loop as they help one in reasoning about the eventual outcome of given pieces of code and in optimizing it where the need be.
Synchronous vs. asynchronous
Let's start by first clearly understanding the intuition behind two commonly used terms in JavaScript conversations: 'synchronous' and 'asynchronous'.
We'll start with synchronous:
Trivially, such an operation is said to execute synchronously.
Let's take an example of some JavaScript code:
var x = 10;
var y = 20;
console.log(x, y);
The code shown here is all synchronous.
First, the program executes the first statement, that is to create a variable in memory and store the value 10
inside it. Only once the variable is successfully created and the value 10
stored in it is this first statement completed.
While this happens, execution doesn't proceed forward. After line 1, we are 100% sure that our variable exists in memory.
The same reasoning applies to the second statement.
Now, let's talk about the third statement, which is a function call. console.log()
is a synchronous function. This means that they return control only once they complete.
One immediate consequence of synchronous execution is that it is possible to reason about the state of a program at any point.
For instance, after line 1 in the code above, we know for sure that a variable x
exists in memory with the value 10
. The same goes for lines 2 and 3 as well.
In synchronous execution, statements are executed serially, one after another, and nothing is offloaded to the background to complete at some point in the future.
Now, let's talk about asynchronous operations, the complement of synchronous operations.
As before, and trivially again, an asynchronous operation is said to execute asynchronously.
The way an asynchronous operation gets carried out is as follows:
- It executes some synchronous instructions to initiate the actual operation (possibly on another thread) before returning control to the caller.
- In the background, the operation happens.
- Once it actually completes, a callback is queued up on a task queue (more on that later below) to be executed on the main thread, whenever there is room for execution there.
Simple.
Let's consider an example of an asynchronous function in JavaScript, and perhaps the simplest to use — setTimeout()
.
For the sake of context, setTimeout()
is used to execute a given function after a certain amount of time elapses since its invocation. That time is given by the second argument to setTimeout()
.
setTimeout()
is an example of an asynchronous function. Basically, this means that it returns control to the script calling it, immediately, while the timer ticks in the background. When the timer completes, the callback is sent to be executed on the main thread as soon as it becomes free.
Had setTimeout()
been synchronous, it would've blocked anything else from being executed on the main thread as long as it's still ticking. As you'd agree, this would've been a poor design choice for setTimeout()
, had it been this way.
As we all know, JavaScript is executed on the main thread inside a web browser alongside other useful things such as rendering HTML and CSS, handling user interactions, and so on. If JavaScript blocks the main thread on a time-consuming operation, it could wreak havoc on the user experience of the corresponding web page.
Hence, setTimeout()
is an asynchronous function.
This leads us to the conclusion that due to the nature of the environment where JavaScript executes, whereby users need to perform fluid interactions with the system, it's intrinsic to offload time-consuming operations to be executed in the background so as to not obstruct the execution of other useful things.
This is the reason why all I/O (input/output) operations in JavaScript — in general, any operation that can potentially consume a good amount of time — is made asynchronous, by design.
The likes of dispatching an HTTP request in JavaScript, using the XMLHttpRequest
API; reading a file selected in a file <input>
element; executing a function after a certain amount of time elapses; making a query to an IndexedDB
store; all these are asynchronous operations in JavaScript, by virtue of the fact that they're all potentially time-consuming.
Some are purely based on a callback approach, for e.g. setTimeout()
, whereas some are based on an event model, for e.g. the FileReader
interface.
In recent years, however, due to the issues associated with building and maintaining code involving nested callbacks and event handlers, another much more neater way has emerged to handle async code in JavaScript. That is to use promises.
So now that we know the distinction between synchronous and asynchronous functions in JavaScript, it's the high time to learn about the actual system used to manage async operations in it.
And that's the event loop.
What's the event loop?
Essentially, the way by which synchronous and asynchronous operations are effortlessly managed in JavaScript is a courtesy of the event loop.
So what exactly is the event loop?
Well, the name is quite self-explanatory in the meaning of the term:
Two things to clarify here:
- Firstly, the term 'event' refers to just about any occurrence that requires some attention, for e.g. the completion of a timer. It doesn't just refer to an actual event dispatched by JavaScript.
- Secondly, the term 'context' refers to a location where JavaScript is run. The reason we didn't use the term 'webpage' here is because the event loop doesn't just run in the context of a webpage; it also runs in the context of iframes and workers.
The term 'loop' in the definition above is indicative of the implementation of this system behind an actual looping construct. The general form could be expressed as follows:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
It's now the high time to discuss three paramount components of the event loop.
Call stack
The call stack is the place where the actual execution of JavaScript code occurs.
The high-level overview of the call stack is as follows: it's a stack data structure whereby each function call in the JavaScript code being executed pushes a new frame on the stack and when it completes, it's popped off the stack.
To begin with, the code represented by a given <script>
tag is placed onto the call stack for execution; then each function creates a new frame on the stack and follows the normal push-pop execution cycle.
If an async function executes in the call stack, such as setTimeout()
, the function exits obviously before the underlying operation completes, but when that operation does complete, the corresponding callback is queued up in another component of the event loop — the task queue.
Task queue
The task queue is the component that manages the relay of tasks, that respond to the completion of async operations, to the call stack.
Sometimes, it's also referred to as the message queue, or the callback queue.
As per the name, the task queue implemented as a queue data structure internally, whereby the task introduced first into it gets entertained first as well (just like a queue in real life).
The event loop only dequeues a task from the task queue when the call stack gets free (after executing some code given to it). While the call stack is busy, the task queue isn't entertained. Although, more tasks might get queued up in the task queue while the call stack is running some code, none of them is taken to the call stack until it's free again.
This explains why the second argument to setTimeout()
specifies the minimum amount of time required before the corresponding callback is invoked, and NOT the exact time.
In fact, it's quite easy to confirm this phenomenon using a blocking while
loop after calling setTimeout()
with a 0 seconds delay.
Consider the following code:
setTimeout(function() {
console.log('Hello World!');
}, 0);
// Block the main thread for 3 seconds.
var d = new Date();
while (new Date() - d < 3000);
First, we call setTimeout()
to execute a function after 0 seconds. Then we have a while
loop, with an empty body, to block the main thread for 3 seconds.
Because setTimeout()
is an asynchronous function, it sets up a timer to complete in the background and, upon completion, queue up the given callback on the task queue to be invoked when the main thread gets free.
Now due to the blocking while
loop, the main thread remains consumed for almost 3 seconds before becoming free to be able to entertain setTimeout()
's callback.
Ultimately, the code above makes a log in the console after 3 seconds.
Microtask queue
The microtask queue is used to handle microtasks and relay them to the call stack.
But what is a microtask?
Well, as the name suggests,
Microtasks represent those tasks that are not that high-priority, or perhaps even feasible, to be executed along with other synchronous code.
But at the same time, microtasks are not even that low-priority to be executed upon the next iteration of the event loop (when another task is dequeued from the task queue to be executed).
JavaScript uses microtasks internally in its Promise
and MutationObserver
APIs. There's even a standard way for userland code to use microtasks, via the queueMicrotask()
function.
We shall learn microtasks in detail in the next chapter, JavaScript Microtasks.