top of page

The JavaScript Event Loop: Explained

Updated: Jul 3, 2023

The JavaScript event loop is a fundamental concept in JavaScript that enables asynchronous programming. JavaScript itself executes all operations on a single thread, but by utilizing smart data structures, it creates the illusion of multithreading. Asynchronous behavior in JavaScript is not built into the language itself, but rather it is implemented on top of the core JavaScript language in the browser or programming environment, accessible through browser APIs.


In both browser-based JavaScript and Node.js, the execution flow is based on an event loop. The event loop operates on a simple concept: an infinite loop where the JavaScript engine waits for tasks, executes them, and then goes to sleep, waiting for more tasks.


The general algorithm followed by the JavaScript engine is as follows:

  1. While there are tasks, execute them in the order they were added, starting from the oldest task.

  2. If there are no tasks, the engine goes to sleep and waits until a new task appears.

  3. Once a new task appears, go back to step 1 and execute it.

This algorithm is what drives the execution of JavaScript code in the browser. Most of the time, the JavaScript engine remains idle, consuming minimal CPU resources. It only activates when a script, event, or handler triggers a task. Some examples of tasks include executing an external script loaded via the <script src="..."> tag, dispatching a mouse move event and executing the associated event handlers when a user moves the mouse, and running the callback function of a scheduled setTimeout.


Tasks are set by various events and actions in the browser environment, and the JavaScript engine handles them in the order they were added to the event loop. This allows for non-blocking execution and efficient handling of multiple tasks.


Understanding the event loop is crucial for writing efficient asynchronous JavaScript code, as it provides insights into how JavaScript handles tasks and manages its execution in an event-driven environment.

Basic Architecture: JavaScript Event Loop

JavaScript Event Loop 1

Memory Heap: Objects in JavaScript are stored in a memory area called the heap. The heap is a large, mostly unstructured region of memory where objects are allocated.


Call Stack: The call stack represents the single thread provided for executing JavaScript code. Whenever a function is called, a new frame is added to the stack. The call stack keeps track of all the operations that need to be executed. When a function finishes, it is removed from the stack. Think of it like a stack of books, where the last book added is the first one to be taken off.


Browser or Web APIs: These are built-in features provided by your web browser. They allow JavaScript to access data from the browser and the surrounding computer environment. Web APIs extend the capabilities of the core JavaScript language and provide additional functionalities. For example, the Geolocation API allows JavaScript to retrieve location data from your device and use it to show your location on a map. The browser APIs use lower-level code, like C++, to interact with the device's hardware, but they abstract away this complexity, providing you with easy-to-use JavaScript constructs.


Event or Callback Queue: The event or callback queue is responsible for managing the order in which functions are processed. It follows the queue data structure, where functions are added to the end of the queue and processed in the same order. When an event occurs or a callback function is triggered, the associated function is placed in the queue and eventually executed by the JavaScript engine.


These components work together to handle the execution of JavaScript code in a browser environment. The memory heap stores objects, the call stack keeps track of function calls, the browser APIs provide additional functionalities, and the event/callback queue manages the order of function execution. Understanding these components helps in understanding how JavaScript code is executed and how different parts of the browser environment interact with it.


JavaScript Event Loop 2


JavaScript Event Loop 3

Event Queue


When an async function is called, it is sent to a browser API. These APIs are built into the browser and can perform operations separately from the JavaScript code. One example is the setTimeout method, which schedules a function to run after a specified time.


The browser API handles the async operation and waits for the specified time. Once the time is up, it sends the operation back to the JavaScript environment for processing. It does this by adding the operation to the event queue.


The event loop is responsible for managing the execution flow in this async system. Its job is to monitor the call stack (which keeps track of function calls) and the callback queue (where async operations are stored). If the call stack is empty, the event loop takes the first event from the queue and adds it to the call stack for execution. If the call stack is not empty, the event loop waits for the current function call to finish before processing the next event.


Let's look at an example to see how this works in a browser environment:

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

bar();
foo();
baz();

Output:

First 
Third 
Second

In this example, the functions foo, bar, and baz are called in order. However, since the bar includes a setTimeout operation, it is sent to the browser API to wait for 500 milliseconds. Meanwhile, foo and baz continue executing. When the specified time elapses, the callback function is added to the event queue. The event loop then picks it up and adds it to the call stack for execution, resulting in the "Second" message being logged last.


Micro-tasks within an event loop:

A micro-task is a function that runs after the current function or program finishes executing and the JavaScript execution stack is empty. It is part of the event loop system used by the browser or user agent to control script execution.


Micro-tasks are often used for tasks that need to be completed immediately after the current script. They have higher priority than other tasks and are executed before moving on to the next task in the event loop.


After each macro-task (larger task) completes, the event loop checks the micro-task queue. If there are any micro-tasks, they are executed one by one until the micro-task queue is empty. Micro-tasks include tasks like promise callbacks and mutation observer callbacks.


Even if new micro-tasks are added while processing the micro-task queue, they will be added to the end of the queue and processed in the order. This ensures consistent task ordering and reduces delays caused by user interactions.


To add a micro-task, you can use functions like queueMicrotask() or specific methods like process.nextTick() or Promise callbacks.


Examples of micro-tasks:

  • process.nextTick(): A Node.js-specific function that schedules a callback to be executed in the next iteration of the event loop.

  • Promises: Promise callbacks are executed as micro-tasks after the current script finishes.

  • queueMicrotask(): A function that adds a micro-task to the micro-task queue.

  • MutationObserver: A Web API that allows you to react to changes in the DOM. Mutation observer callbacks are executed as micro-tasks.

The syntax for adding a micro-task:

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

Note: Micro-tasks take no parameters and do not return a value.

Macro-tasks within an event loop:

Macro-tasks are discrete and independent units of work in JavaScript. They represent the execution of JavaScript code when the micro-task queue is empty. The macro-task queue is also known as the task queue or event queue. It handles asynchronous statements in JavaScript.


In JavaScript, code execution is event-driven, meaning it waits for an event to occur before executing any code. The execution of JavaScript code itself is a macro-task. Events are queued as macro-tasks, and when a macro-task in the queue is executed, new events can be registered and added to the queue.


When the JavaScript engine initializes, it takes the first task from the macro-task queue and executes its callback handler. Asynchronous functions are sent to the API module, which adds them to the macro-task queue at the appropriate time. Each macro-task inside the queue waits for the next round of the event loop for execution.


Micro-tasks, on the other hand, are processed in a single macro-task execution cycle. They have a higher priority than macro-tasks. Examples of macro-tasks include parsing HTML, generating the DOM, executing main thread JavaScript code, and handling events like page loading, input, network events, and timer events.


Examples of macro-tasks:

  • setTimeout: A function that schedules a callback to be executed after a specified delay.

  • setInterval: A function that repeatedly executes a callback at a specified interval.

  • setImmediate: A function that schedules a callback to be executed in the next iteration of the event loop.

  • requestAnimationFrame: A function that schedules a callback to be executed before the next repaint of the browser.

  • I/O: Input/output operations such as reading and writing to files or network requests.

  • UI Rendering: Tasks related to rendering the user interface.

Let's consider a quick example using tasks and micro-tasks:

  • Task1: A function that is added to the call stack immediately, executed instantly in our code.

  • Task2, Task3, Task4: Micro-tasks, such as promisesTask 1 then callbacks or tasks added with queueMicrotask.

  • Task5, Task6: Macro-tasks, such as setTimeout or setImmediate callbacks.


JavaScript Event Loop 5

In the execution process, Task1 is executed and removed from the call stack. Then, the engine checks for tasks in the micro-task queue. Once all the micro-tasks are processed, the engine moves on to tasks in the macro-task queue, which are executed and removed from the call stack.

Code Example 1: Using setTimeout and Promise


console.log('Start!')

setTimeout(() => {
    concole.log ('Timeout!')
}, 0)

Promise.resolve ('Promise!')
    .then (res => concole.log(res))

concole.log('End!')
  • The code starts with a console.log() statement, which logs "Start!" to the console.

  • The setTimeout function is called and schedules a callback function to be executed after a specified delay. In this case, the delay is set to 0 milliseconds. The callback function logs "Timeout!" to the console.

  • Promise.resolve() creates a resolved promise with the value "Promise!". The then() method is called on the promise, and its callback function is added to the microtask queue.

  • Another console.log() statement is executed, logging "End!" to the console.

  • The microtask queue is checked since the call stack is empty. The promise's then() callback is executed, logging the resolved value "Promise!" to the console.

  • The macro task queue is checked next, and the setTimeout callback function is executed, logging "Timeout!" to the console.

Code Example 2: Async/Await

  • The code begins with a console.log() statement, which logs "Before function!" to the console.

  • An async function called myFunc() is invoked. Inside the function, a console.log() statement is executed, logging "In function!" to the console.

  • The function encounters the await keyword, which suspends the async function. The function execution pauses, and the remaining part of the async function is treated as a microtask.

  • The code continues executing in the global execution context. There are no more tasks in this context.

  • The event loop checks for micro tasks and finds the suspended async function myFunc. It is added back to the call stack and resumes execution.

  • The value awaited by the await keyword (a function called one) is executed and returns a resolved promise.

  • The promise's resolved value is assigned to the variable res. A console.log() statement is executed with the value of res, logging "One!" to the console.

  • The execution is completed, and the async function finishes.

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.

Comentários


bottom of page