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?
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.
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)
, andfinally(callback)
)Execution of
async
function bodies followingawait
MutationObserver
callbacksqueueMicrotask
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 theCall Stack
.The
Task Queue
is used by callback-basedWeb APIs
to enqueue the callbacks once the asynchronous task has completed.The
Microtask Queue
is used by Promise handlers,async
function bodies followingawait
,MutationObserver
callbacks andqueueMicrotask
callbacks. This queue has priority over theTask Queue
.When the
Call Stack
is empty, theEvent Loop
first moves tasks from theMicrotask Queue
until this queue is completely empty. Then, it moves on to theTask Queue
, where it moves the first available task to theCall Stack
. After handling the first available task, it "starts over" by again checking theMicrotask 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.
This leverages the full power of promises, such as better readability and the use of async
/await
syntax.
We can even create a Promise-based timer using setTimeout
to defer the execution of a code block until the timer has expired:
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!