The JavaScript Event Loop: Explained

Call Stack, Web APIs, Event Queue, Micro-tasks, and Macro-tasks

Ayush Verma
Towards Dev
Published in
11 min readApr 13, 2021

--

The event loop is the secret behind JavaScript’s asynchronous programming. JS executes all operations on a single thread, but using a few smart data structures, gives us the illusion of multi-threading. The asynchronous behavior is not part of the JavaScript language itself, rather it is built on top of the core JavaScript language in the browser (or the programming environment) and accessed through the browser APIs.

Browser JavaScript execution flow, as well as in Node.js, is based on an event loop. The event loop concept is very simple. There’s an endless loop, where the JavaScript engine waits for tasks, executes them, and then sleeps, waiting for more tasks.

The general algorithm of the engine:

  1. While there are tasks - execute them, starting with the oldest task.
  2. Sleep until a task appears, then go to 1.

That’s a formalization for what we see when browsing a page. The JavaScript engine does nothing most of the time, it only runs if a script/handler/event activates. Examples of tasks:

  • When an external script <script src="..."> loads, the task is to execute it.
  • When a user moves their mouse, the task is to dispatch mousemove event and execute handlers.
  • When the time is due for a scheduled setTimeout, the task is to run its callback.
  • …and so on.

Tasks are set — the engine handles them — then waits for more tasks (while sleeping and consuming close to zero CPU). Let’s take a look at what happens on the back-end.

Basic Architecture

  • Memory Heap — Objects are allocated in a heap which is just a name to denote a large mostly unstructured region of memory.
  • Call Stack — This represents the single thread provided for JavaScript code execution. Function calls form a stack of frames. It is responsible for keeping track of all the operations in line to be executed. Whenever a function is finished, it is popped from the stack. It is a LIFO queue (Last In, First Out).
  • Browser or Web APIs — They are built into your web browser and are able to expose data from the browser and surrounding computer environment and do useful complex things with it. They are not part of the JavaScript language itself, rather they are built on top of the core JavaScript language, providing you with extra superpowers to use in your JavaScript code.
    For example, the Geolocation API provides some simple JavaScript constructs for retrieving location data so you can say, plot your location on a Google Map. In the background, the browser is actually using some complex lower-level code (e.g. C++) to communicate with the device’s GPS hardware (or whatever is available to determine position data), retrieve position data, and return it to the browser environment to use in your code. But again, this complexity is abstracted away from you by the API.
  • Event or Callback Queue — It is responsible for sending new functions to the track for processing. It follows the queue data structure to maintain the correct sequence in which all operations should be sent for execution.
Call stack and Event loop
Event Queue

Whenever an async function is called, it is sent to a browser API. These are APIs built into the browser. Based on the command received from the call stack, the API starts its own single-threaded operation.

An example of this is the setTimeout method. When a setTimeout operation is processed in the stack, it is sent to the corresponding API which waits till the specified time to send this operation back in for processing.

Where does it send the operation? The event queue. Hence, we have a cyclic system for running async operations in JavaScript. The language itself is single-threaded, but the browser APIs act as separate threads.

The event loop facilitates this process. It has one simple job — to monitor the call stack and the callback queue. If the call stack is empty, the event loop will take the first event from the queue and will push it to the call stack, which effectively runs. If it is not, then the current function call is processed.

Let’s quickly take a look at an example and see what’s happening when we’re running the following code in a browser:

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();
Output:First
Third
Second
  1. We invoke bar. bar returns a setTimeout function.
  2. The callback we passed to setTimeout gets added to the Web API, the setTimeout function and bar get popped off the callstack.
  3. The timer runs, in the meantime foo gets invoked and logs First. foo returns (undefined),baz gets invoked, and the callback gets added to the queue.
  4. baz logs Third. The event loop sees the callstack is empty after baz returned, after which the callback gets added to the call stack.
  5. The callback logs Second.

The event loop proceeds to execute all the callbacks waiting in the task queue. Inside the task queue, the tasks are broadly classified into two categories, namely micro-tasks and macro-tasks.

Micro-tasks within an event loop:

  • A micro-task is said to be a function that is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script’s execution environment. A Micro-task is also capable of en-queuing other micro-tasks.
  • Micro-tasks are often scheduled for things that are required to be completed immediately after the execution of the current script. On completion of one macro-task, the event loop moves on to the micro-task queue. The event loop does not move to the next task outside of the micro-task queue until all the tasks inside the micro-task queue are completed. This implies that the micro-task queue has a higher priority.
  • Once all the tasks inside the micro-task queue are finished, only then does the event loop shift back to the macro-task queue. The primary reason for prioritizing the micro-task queue is to improve the user experience. The micro-task queue is processed after callbacks given that any other JavaScript is not under mid-execution. Micro-tasks include mutation observer callbacks as well as promise callbacks.
  • In such a case wherein new micro-tasks are being added to the queue, these additional micro-tasks are added at the end of the micro-queue and these are also processed. This is because the event loop will keep on calling micro-tasks until there are no more micro-tasks left in the queue, even if new tasks keep getting added. Another important reason for using micro-tasks is to ensure consistent ordering of tasks as well as simultaneously reducing the risk of delays caused by users.

Syntax: Adding micro-tasks:

queueMicrotask(() => {
// Code to be run inside the micro-task
});

The micro-task function itself takes no parameters and does not return a value.

Examples: process.nextTick, Promises, queueMicrotask, MutationObserver

Macro-tasks within an event loop:

  • Macro-task represents some discrete and independent work. These are always the execution of the JavaScript code and micro-task queue is empty. Macro-task queue is often considered the same as the task queue or the event queue. However, the macro-task queue works the same as the task queue. The only small difference between the two is that the task queue is used for synchronous statements whereas the macro-task queue is used for asynchronous statements.
  • In JavaScript, no code is allowed to execute until an event has occurred. It is worth mentioning that the execution of a JavaScript code execution is itself a macro-task. The event is queued as a macro-task. When a (macro) task, present in the macro-task queue is being executed, new events may be registered and in turn, created and added to the queue.
  • Upon initialization, the JavaScript engine first pulls off the first task in the macro-task queue and executes the callback handler. The JavaScript engine then sends these asynchronous functions to the API module, and the module pushes them to the macro-task queue at the right time. Once inside the macro-task queue, each macro-task is required to wait for the next round of the event loop. In this way, the code is executed.
  • All micro-tasks logged are processed in one fell swoop in a single macro-task execution cycle. In comparison, the macro-task queue has a lower priority. Macro-tasks include parsing HTML, generating DOM, executing main thread JavaScript code, and other events such as page loading, input, network events, timer events, etc.

Examples: setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI Rendering

Let’s take a look at a quick example, simply using:

  • Task1: a function that's added to the call stack immediately, for example by invoking it instantly in our code.
  • Task2, Task3, Task4: microtasks, for example, a promise then callback, or a task added with queueMicrotask.
  • Task5, Task6: a (macro)task, for example, a setTimeout or setImmediate callback

First, Task1 returned a value and got popped off the call stack. Then, the engine checked for tasks queued in the microtask queue. Once all the tasks were put on the call stack and eventually popped off, the engine checked for tasks on the macro task queue, which got popped onto the call stack, and popped off when they returned a value. Let’s use it with some real code:

1) Using setTimeout and Promise

In this code, we have the macro task setTimeout and the microtask promise then() callback. Let's run this code step-by-step, and see what gets logged:

  1. On the first line, the engine encounters the console.log() method. It gets added to the call stack, after which it logs the value Start! to the console. The method gets popped off the call stack, and the engine continues.
  2. The engine encounters the setTimeout method, which gets popped onto the call stack. The setTimeout method is native to the browser: its callback function (() => console.log('Timeout!')) will get added to the Web API until the timer is done. Although we provided the value 0 for the timer, the call back still gets pushed to the Web API first, after which it gets added to the (macro) task queue: setTimeout is a macro task!

3. The engine encounters the Promise.resolve() method. The Promise.resolve() method gets added to the call stack, after which is resolves with the value Promise!. Its then callback function gets added to the microtask queue.

4. The engine encounters the console.log() method. It gets added to the call stack immediately, after which it logs the value End! to the console, gets popped off the call stack, and the engine continues.

5. The engine sees the call stack is empty now. Since the call stack is empty, it’s going to check whether there are queued tasks in the microtask queue! And yes there are, the promise then callback is waiting for its turn! It gets popped onto the call stack, after which it logs the resolved value of the promise: the string Promise!in this case.

6. The engine sees the call stack as empty, so it’s going to check the microtask queue once again to see if tasks are queued. Nope, the microtask queue is all empty. It’s time to check the (macro)task queue: the setTimeout callback is still waiting there! The setTimeout callback gets popped onto the call stack. The callback function returns the console.log method, which logs the string "Timeout!". The setTimeout callback gets popped off the call stack.

2) Async/Await

  1. First, the engine encounters a console.log. It gets popped onto the call stack, after which Before function! gets logged.
  2. Then, we invoke the async function myFunc(), after which the function body myFunc runs. On the very first line within the function body, we call another console.log, this time with the string In function!. The console.log gets added to the call stack, logs the value, and gets popped off.
  3. The function body keeps on being executed, which gets us to the second line. Finally, we see a await keyword! 🎉 The first thing that happens is that the value that gets awaited gets executed: the function one in this case. It gets popped onto the call stack, and eventually returns a resolved promise. Once the promise has resolved and one returned a value, the engine encounters the await keyword.
  4. When encountering an await keyword, the async function gets suspended. ✋🏼 The execution of the function body gets paused, and the rest of the async function gets to run in a microtask instead of a regular task!
  5. Now that the async function myFunc is suspended as it encountered the await keyword, the engine jumps out of the async function and continues executing the code in the execution context in which the async function got called: the global execution context in this case! 🏃🏽‍♀️
  6. Finally, there are no more tasks to run in the global execution context! The event loop checks to see if there are any microtasks queued up: and there are! The async myFunc function is queued up after resolving the valued of one. myFunc gets popped back onto the call stack, and continues running where it previously left off.
  7. The variable res finally gets its value, namely the value of the resolved promise that one returned! We invoke console.log with the value of res: the string One! in this case. One! gets logged to the console and gets popped off the call stack! 😊

Finally, all done! Did you notice how async functions are different compared to a promise then? The await keyword suspends the async function, whereas the Promise body would've kept on being executed if we would've used then!

Conclusion

We have learned about the JavaScript event loop which is a constantly running process that coordinates the tasks between the call stack and callback queue to achieve concurrency.

Hope that this makes you feel a bit more comfortable with the concept of the event loop. Thank you for reading :)

--

--

Published in Towards Dev

A publication for sharing projects, ideas, codes, and new theories.

Written by Ayush Verma

Web developer who loves to code and help others code :)

Responses (5)

Write a response