JavaScript Visualized:
Event Loop, Web APIs, (Micro)task Queue

April 4, 2024

The Event Loop is… a notorious topic. However, when we zoom out, it's just a tiny component within the JavaScript runtime!

The Event Loop plays a big role in the handling of asynchronous tasks! And this is important since JavaScript is single-threaded - we're only working with a single Call Stack.

Call Stack

The Call Stack manages the execution of our program. When we invoke a function, a new execution context gets created which gets pushed onto the Call Stack. The top-most function in the call stack is evaluated, which could in turn invoke another function, and so on.

An execution context is popped off the Call Stack when the function completed its execution.

JavaScript is only able to handle one task at a time; if one task is taking too long to pop off, no other tasks can get handled.

This means that longer running tasks can block any other task from executing, essentially freezing our program!

In this case, the importantTask had to wait until longRunningTask had been popped off the Call Stack, which took a while due to the expensive computation within its function body.

But wait… To build an actual application, we often need some longer-running tasks. Things like network requests, timers, or just anything that's based on user input.

Does this mean that our entire application freezes when we use such long-running tasks?

fetch("https://website.com/api/posts") 
// We don't know when the data gets returned from the server...
// Would this be on the call stack until the data returns?

Luckily, no! Such functionality isn't actually part of JavaScript itself; it's provided to us through Web APIs.

Web APIs

Web APIs provide a set of interfaces to interact with features that the browser leverages. This includes functionality that we frequently use when building with JavaScript, such as the Document Object Model, fetch, setTimeout, and many more.

The browser is a powerful platform that leverages tons of features. Some of them are required for us to build functional applications, such as the Rendering Engine to display content or the Networking Stack for network requests.

We even have access to some lower-level features, like the device's sensors, cameras, geolocation, and so on.

Web APIs essentially act as a bridge between the JavaScript runtime and the browser features, allowing us to access information and use features beyond JavaScript's own capabilities.

Okay cool, but what does this have to do with non-blocking tasks?

Some Web APIs allow us to initiate async tasks, and offload longer-running tasks to the browser!

Invoking a method exposed by such an API is really just to offload the longer-running task to the browser environment, and set up handlers to handle the eventual completion of this task.

After initiating the async task (without waiting for the result), the execution context can quickly get popped off the Call Stack; it's non-blocking!

Web APIs that expose async capabilities either use a callback-based or promise-based approach. .

First, let's talk about the callback approach.

Callback-based APIs

Let’s take the Geolocation API as an example. On our website, we want to get access to the user’s location.

To get this, we can use the getCurrentPosition method which receives two callbacks: the successCallback that is used when we successfully received the user's location, and the optional errorCallback in case anything went wrong.

Invoking this function pushes its newly created execution context onto the Call Stack. This is really just to "register" its callbacks to the Web API, which then offloads the operation to the browser.

The function is then popped off the Call Stack; it's now the browser's responsibility.

Behind the scenes, the browser prompts the user to give the website access to their location.

We don't actually know when the user will interact with our prompt, maybe they get distracted or they simply don't see the popup appear.

But that's no problem! As this is all happening in the background, the Call Stack remains available to take on and execute other tasks. Our website remains responsive and interactive.

Finally, the user allowed our website to get access to their location. The API now receives the data from the browser, and uses the successCallback to handle the result.

However, the successCallback can't simply get pushed onto the Call Stack, as doing so could potentially disrupt an already running task, which would lead to unpredictable behavior and potential conflicts.

The JavaScript engine can handle tasks one at a time, which ensures a predictable and organized execution environment.

Task Queue

Instead, the successCallback is added to the Task Queue (also called the Callback Queue for this exact reason). The Task Queue holds Web API callbacks and event handlers waiting to be executed at some point in the future.

Okay so now the successCallback is on the task queue... But when does it get executed?

Event Loop

Finally, we get to the Event Loop! It's the responsibility of the Event Loop to continuously check if the Call Stack is empty.

Whenever the Call Stack is empty — meaning there are no currently running tasks — it takes the first available task from the Task Queue and moves this onto the Call Stack, where the callback gets executed.

The Event Loop continuously checks if the call stack is empty, and if that's the case, checks for the first available task in the Task Queue, and moves this to the Call Stack for execution.

Another popular callback-based Web API is setTimeout. Whenever we call setTimeout, the function call is pushed onto the Call Stack, which is only responsible for initiating a timer with the specified delay. In the background, the browser keeps track of the timers.

Once a timer expires, the timer's callback is enqueued on the Task Queue! It's important to remember that the delay specifies the time after which the callback is pushed to the Task Queue, not the Call Stack.


This means that the actual delay to execution might be longer than the specified delay passed to setTimeout! If the Call Stack is still busy handling other tasks, the callback would have to wait in the Task Queue.

So far, we've seen how callback-based APIs are handled. However, most modern Web APIs use a promise-based approach, and, as you may have guessed, these are handled differently.

I'll assume some basic knowledge about Promises from here on. My video on Promises could help if you need a refresher!

Microtask Queue

Most (modern) Web APIs return a promise allow us to handle the returned data through chaining promise handlers (or by using await) instead of using callbacks.

fetch("...")
  .then(res => ...)
  .catch(err => (...)

Since we're handling the data in a promise handler, we're using the Microtask Queue!

The Microtask Queue is another queue in the runtime with a higher priority than the Task Queue. This queue is specifically dedicated to:

  • Promise handler callbacks (then(callback), catch(callback), and finally(callback))

  • Execution of async function bodies following await

  • MutationObserver callbacks

  • queueMicrotask callbacks

When the Call Stack is empty, the Event Loop first processes all microtasks from the Microtask Queue before moving on to the Task Queue.

After completing a single task from the Task Queue, and the Call Stack is empty, the Event Loop effectively "starts over" by processing all microtasks in the Microtask Queue before again moving on to the next task.

This ensures that microtasks related to the just-completed task are handled immediately, maintaining the program's responsiveness and consistency.

Microtasks can also schedule other microtasks! This could create a scenario where we create en infinite microtask loop, delaying the Task Queue indefinitely and freezing the rest of the program. So be careful!

Such a scenario cannot (!) happen on the Task Queue. The Event Loop processes tasks on the Task Queue one by one, then it "starts over" by checking the Microtask Queue.

A popular promise-based API is fetch. When we invoke fetch, its execution context is added to the Call Stack.

Calling fetch creates a Promise Object in memory, which is "pending" by default. After initiating the network request, the fetch function call is popped off the Call Stack.

The engine now encounters the chained then handler, which creates a PromiseReaction record that's stored in PromiseFulfillReactions .

Next, the console.log is pushed to the Call Stack , and logs End of script to the console. In this case, the network request is still pending.

When the server finally returns the data, the [[PromiseStatus]] is set to "fulfilled" , the [[PromiseResult]] is set to the Response object. As the promise resolves, the PromiseReaction is pushed onto the Microtask Queue.

When the Call Stack is empty, the Event Loop moves the handler callback from the Microtask Queue onto the the Call Stack, where it's executed, logs the Response object, and eventually popped off the call stack.

Are all Web APIs handled asynchronously?

No, just the ones that initiate asynchronous operations. Other methods, for example document.getElementById() or localStorage.setItem() , are handled synchronously.

Recap

Let's recap what we covered so far:

  • JavaScript is single-threaded, meaning it can only handle one task at a time.

  • Web APIs are used to interact with features leveraged by the browser. Some of these APIs allow us to initiate async tasks in the background.

  • The function call that initiates the async task is added to the Call Stack, but that is just to hand it off to the browser. The actual async task is handled in the background, and does not remain on the Call Stack.

  • The Task Queue is used by callback-based Web APIs to enqueue the callbacks once the asynchronous task has completed.

  • The Microtask Queue is used by Promise handlers, async function bodies following await, MutationObserver callbacks andqueueMicrotask callbacks. This queue has priority over the Task Queue.

  • When the Call Stack is empty, the Event Loop first moves tasks from the Microtask Queue until this queue is completely empty. Then, it moves on to the Task Queue, where it moves the first available task to the Call Stack. After handling the first available task, it "starts over" by again checking the Microtask Queue.

Promisifying callback-based APIS

To enhance readability and manage the flow of async operations in callback-based Web APIs, we can wrap them in a Promise.

For example, we can wrap the Geolocation API's callback-based getCurrentPosition method in a Promise constructor.

function getCurrentPosition() {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject);
  });
}

This leverages the full power of promises, such as better readability and the use of async/await syntax.

async function fetchAndLogCurrentPosition() {
  try {
    const position = await getCurrentPosition();
    console.log(position);
  } catch (error) {
    console.log(error);
  }
}

// function fetchAndLogCurrentPosition() {
//   getCurrentPosition()
//     .then((position) => console.log(position))
//     .catch((error) => console.error(error));
// }

We can even create a Promise-based timer using setTimeout to defer the execution of a code block until the timer has expired:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

async function doStuff() {
  // Perform tasks...
  await delay(5000);
  // Perform the rest after 5 seconds
}

Conclusion

Understanding how the Event Loop, Task Queue, and Microtask Queue work together is important to master asynchronous, non-blocking JavaScript.

The Event Loop orchestrates the execution of tasks, prioritizing the Microtask Queue to ensure promises and related operations are resolved quickly before moving on to tasks in the Task Queue.

This dynamic enables JavaScript to handle complex asynchronous behavior in a single-threaded environment.

Feedback

If you have any feedback for this article, let me know!