JavaScript Event Loop

Chapter 4 14 mins

Learning outcomes:

  1. Synchronous vs. asynchronous code
  2. What is the event loop
  3. The call stack
  4. The task queue, a.k.a. the message queue
  5. A glimpse into microtasks

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:

An operation is said to be synchronous if it returns control to the caller only once the operation reaches completion.

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.

As a matter of fact, the majority of functions and APIs in JavaScript are synchronous. Comparatively, there are only a handful of asynchronous functions in JavaScript, just like drops in the ocean.

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.

An operation is said to be asynchronous if it returns control to its caller before it reaches completion.

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.

We'll explore promises in granular detail in the Advanced JavaScript — Promises unit.

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:

An event loop is a loop that runs indefinitely to entertain any events occurring in a JavaScript context.

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);

Live Example

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,

A microtask is a simple task that's not necessarily required to be executed synchronously along with other code.

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.