Join us for our biggest AI launch event on 10/31

Announcing Visual Copilot - Figma to production in half the time

Builder.io logo
Contact Sales
Platform
Developers
Contact Sales

Blog

Home

Resources

Blog

Forum

Github

Login

Signup

×

Visual CMS

Drag-and-drop visual editor and headless CMS for any tech stack

Theme Studio for Shopify

Build and optimize your Shopify-hosted storefront, no coding required

Resources

Blog

Get StartedLogin

‹ Back to blog

Web Development

A Complete Visual Guide to Understanding the Node.js Event Loop

March 23, 2023

Written By Vishwas Gopinath

You've been working with Node.js for a while. You've built some apps, played around with different modules, and even gotten comfortable with asynchronous programming. But there's something that's been nagging at you — the event loop.

If you’re like me, you’ve spent countless hours reading documentation and watching videos, trying to understand the event loop in Node.js. But even as an experienced developer, it can be tough to get a complete picture of how it all works. That is why I’ve put together this visual guide to help you fully understand the Node.js event loop. Sit back, grab a cup of coffee, and let’s dive deep into the world of the Node.js event loop.

Asynchronous programming in JavaScript

We will begin with a refresher on asynchronous programming in JavaScript. Although JavaScript finds its usage in web, mobile, and desktop applications, it’s important to remember that in its most basic form, JavaScript is a synchronous, blocking, single-threaded language. Let’s understand that line with a short snippet of code.

// index.js

function A() {
  console.log("A");
}

function B() {
  console.log("B");
}

A()
B()

// Logs A and then B

If we have two functions that log messages to the console, the code executes top-down, with only one line executing at any given time. In the code snippet, we see A is logged before B.

JavaScript is blocking because of its synchronous nature. No matter how long a previous process takes, the subsequent processes won't kick off until the former is completed. In the code snippet, if function A has to execute an intensive chunk of code, JavaScript has to finish that without moving on to function B. Even if that code takes 10 seconds or 1 minute.

You might have experienced this in the browser. When a web app runs in a browser and it executes an intensive chunk of code without returning control to the browser, the browser can appear to be frozen. This is called blocking. The browser is blocked from continuing to handle user input and perform other tasks until the web app returns control of the processor.

A thread is simply a process that your JavaScript program can use to run a task. And each thread can only do one task at a time. Unlike a few other languages which support multi-threading and can thus run multiple tasks in parallel, JavaScript has just one thread called the main thread for executing any code.

As you may have guessed, this model of JavaScript creates a problem because we have to wait for data to be fetched before we can continue code execution. This wait can take several seconds, during which we can't run any further code. If JavaScript proceeds without waiting, we will encounter an error. We need a way to have asynchronous behavior in JavaScript. Enter Node.js.

Image describing the node.js runtime.

The Node.js runtime is an environment where you can use and run a JavaScript program outside of a browser. At its core, the Node runtime consists of three major components.

  • External dependencies — such as V8, libuv, crypto — required by Node.js for its functioning
  • C++ features that provide for functionality such as file system access and networking
  • A JavaScript library that provides functions and utilities to tap into the C++ features from your JavaScript code

While all the parts are important, the key component for asynchronous programming in Node.js is the external dependency, libuv.

Libuv is a cross-platform open-source library written in C. In the Node.js runtime, its role is to provide support for handling asynchronous operations. Let's go over how this works.

Image with rectangular block representing V8 engine on the left and rectangular block representing libuv on the right

Let’s conceptualize how code typically executes in the Node runtime . When we execute code, the V8 engine, located on the left side of the image, handles the execution of JavaScript code. The engine comprises a memory heap and a call stack.

Whenever we declare variables or functions, memory is allocated on the heap, and whenever we execute code, functions are pushed into the call stack. When a function returns, it is popped off the call stack. This is a straightforward implementation of the stack data structure, where the last item added is the first one to be removed. On the right side of the image, we have libuv, which is responsible for handling asynchronous methods.

Whenever we execute an asynchronous method, libuv takes over the execution of the task. Libuv then runs the task using native asynchronous mechanisms of the operating system. In case the native mechanisms are not available or inadequate, it utilizes its thread pool to run the task, ensuring that the main thread is not blocked.

First, let's take a look at synchronous code execution. The following code consists of three console log statements that log "First", "Second", and "Third" one after the other. Let's go through the code as if the runtime were executing it.

// index.js
console.log("First");
console.log("Second");
console.log("Third");

Below is how synchronous code execution can be visualized with the Node runtime.

The main thread of execution always starts in the global scope. The global function, if we can call it that, is pushed onto the stack. Then, on line 1, we have a console log statement. The function is pushed onto the stack. Assuming this happens at 1 ms, "First" is logged to the console. Then, the function is popped off the stack.

Execution comes to line 3. Let's say at 2ms, the log function is again pushed onto the stack. "Second" is logged to the console, and the function is popped off the stack.

Finally, execution is on line 5. At 3ms, the function is pushed onto the stack, "Third" is logged to the console, and the function is popped off the stack. There is no more code to execute, and global is also popped off.

Visually build with your components

Builder.io is a headless CMS that lets you drag and drop with your components.

// Dynamically render your components
export function MyPage({ json }) {
  return <BuilderComponent content={json} />
}

registerComponents([MyHero, MyProducts])

Next, let's take a look at asynchronous code execution. Consider the code snippet below. There are three log statements, but this time the second log statement is within a callback function passed to fs.readFile().

The main thread of execution always starts in the global scope. The global function is pushed onto the stack. Execution then comes to line 1. At 1ms, "First" is logged in the console, and the function is popped off the stack. Execution then moves on to line 3. At 2ms, the readFile method is pushed onto the stack. Since readFile is an asynchronous operation, it is off-loaded to libuv.

JavaScript pops off the readFile method from the call stack because its job is done as far as the execution of line 3 is concerned. In the background, libuv starts to read the file contents on a separate thread. At 3ms, JavaScript proceeds to line 7, pushes the log function onto the stack, "Third" is logged to the console, and the function is popped off the stack.

At about 4ms, let's say that the file read task is completed in the thread pool. The associated callback function is now executed on the call stack. Within the callback function, the log statement is encountered.

That is pushed to the call stack, "Second" is logged to the console, and the log function is popped off. As there are no more statements to execute in the callback function, it is popped off as well. There's no more code to run, so the global function is also popped off the stack.

The console output is going to read "First", "Third", and then "Second".

It is pretty clear libuv helps handle asynchronous operations in Node.js. For async operations like handling a network request, libuv relies on the operating system primitives. For async operations like reading a file that has no native OS support, libuv relies on its thread pool to ensure that the main thread is not blocked. However, that does inspire a few questions.

  • When an async task completes in libuv, at what point does Node decide to run the associated callback function on the call stack?
  • Does Node wait for the call stack to be empty before running the callback function, or does it interrupt the normal flow of execution to run the callback function?
  • What about other async methods like setTimeout and setInterval, which also delay the execution of a callback function?
  • If two async tasks such as setTimeout and readFile complete at the same time, how does Node decide which callback function to run first on the call stack? Does one get priority over the other?

All of these questions can be answered by understanding the core part of libuv, which is the event loop.

Technically, the event loop is just a C program. But, you can think of it as a design pattern that orchestrates or coordinates the execution of synchronous and asynchronous code in Node.js. The event loop runs continuously as long as your Node.js application is up and running, handling multiple operations executing concurrently.

The event loop is a loop that runs as long as your Node.js application is up and running. There are six different queues in every loop, each holding one or more callback functions that need to be executed on the call stack eventually.

Event loop consisting of 6 different queues.
  • First, there is the timer queue (technically a min-heap), which holds callbacks associated with setTimeout and setInterval.
  • Second, there is the I/O queue which contains callbacks associated with all the async methods such as methods associated with the fs and http modules.
  • Third, there is the check queue which holds callbacks associated with the setImmediate function, which is specific to Node.
  • Fourth, there is the close queue which holds callbacks associated with the close event of an async task.

Finally, there is the microtask queue which contains two separate queues.

  • nextTick queue which holds callbacks associated with the process.nextTick function.
  • Promise queue which holds callbacks associated with the native Promise in JavaScript.

It is important to note that the timer, I/O, check, and close queues are all part of libuv. The two microtask queues, however, are not part of libuv. Nevertheless, they are still part of the Node runtime and play an important role in the order of execution of callbacks. Speaking of which, let's understand that next.

The arrowheads are already a giveaway, but it's easy to get confused as the event loop executes callbacks in a specific order. Let me explain the priority order of the queues. First, know that all user-written synchronous JavaScript code takes priority over async code that the runtime would like to execute. This means that only after the call stack is empty does the event loop come into play.

Within the event loop, each phase executes callbacks scheduled for that particular queue but there are quite a few rules to wrap your head around, so let's go over them one at a time:

  1. Any callbacks in the microtask queue are executed. First, tasks in the nextTick queue and only then tasks in the promise queue.
  2. All callbacks within the timer queue are executed (timers phase). These represent expired timer callbacks that are now ready to be processed.
  3. Callbacks in the microtask queue (if present) are executed after every callback in the timer queue. First, tasks in the nextTick queue, and then tasks in the promise queue.
  4. All callbacks within the I/O queue are executed (I/O phase).
  5. Callbacks in the microtask queues (if present) are executed, starting with nextTickQueue and then Promise queue.
  6. All callbacks in the check queue are executed (check phase).
  7. Callbacks in the microtask queues (if present) are executed after every callback in the check queue. First, tasks in the nextTick queue, and then tasks in the promise queue.
  8. All callbacks in the close queue are executed (close callbacks phase).
  9. For one final time in the same loop, the microtask queues are executed. First, tasks in the nextTick queue, and then tasks in the promise queue.

If there are more callbacks to be processed at this point, the loop is kept alive for one more run, and the same steps are repeated. On the other hand, if all callbacks are executed and there is no more code to process, the event loop exits.

This is the role that libuv's event loop plays in the execution of asynchronous code in Node.js. With these rules in mind, we can revisit the questions from earlier.

When an async task completes in libuv, at what point does Node decide to run the associated callback function on the call stack?

Answer:

Callback functions are executed only when the call stack is empty.

Does Node wait for the call stack to be empty before running the callback function, or does it interrupt the normal flow of execution to run the callback function?

Answer:

The normal flow of execution will not be interrupted to run a callback function.

What about other async methods like setTimeout and setInterval, which also delay the execution of a callback function?

Answer:

setTimeout and setInterval callbacks are given first priority.

If two async tasks such as setTimeout and readFile complete at the same time, how does Node decide which callback function to run first on the call stack? Does one get priority over the other?

Answer:

Timer callbacks are executed before I/O callbacks, even if both are ready at the exact same time.

There was a lot more we learned but this visual representation below (which is the same as above) is what I would like you to imprint in your mind as it shows how Node.js executes asynchronous code under the hood.

Event loop consisting of the 6 different queues.

"But wait, where's the code that verifies this visualization?" you may ask. Well, each queue in the event loop has nuances in execution, so it's optimal to deal with them one at a time. This post is the first in a series of blog posts on the event loop in Node.js. Be sure to check out the other parts linked below to understand a few gotchas that might trip you up, even with this image imprinted in your mind.

The event loop is a fundamental part of Node.js that enables asynchronous programming by ensuring the main thread is not blocked. Understanding how the event loop works can be challenging, but it is essential for building performant applications that can handle multiple client requests and async API calls efficiently.

This visual guide has covered the basics of asynchronous programming in JavaScript, the Node.js runtime, and libuv, which is responsible for handling asynchronous operations. With this knowledge, you can build a strong mental model of the event loop, which will help you write code that takes advantage of Node.js' asynchronous nature.

Introducing Visual Copilot: convert Figma designs to high quality code in a single click.

Try Visual Copilot

Share

Twitter
LinkedIn
Facebook
Hand written text that says "A drag and drop headless CMS?"

Coming soon: add interactivity and data to your designs

Reserve Your Spot
Newsletter

Like our content?

Join Our Newsletter

Continue Reading
Visual Editing7 MIN
Visual editing is bridging the gap between developers and designers
October 11, 2024
SEO10 MIN
A helpful approach to navigating the SEO AI shift
October 3, 2024
Personalization12 MIN
High-Performance Personalization For Modern Frontends
September 26, 2024