Demystifying Node.js: Understanding its Single-Threaded Nature

Spoiler alert: there are threads!

Miri Yehezkel
5 min readApr 24, 2024

Node.js has gained immense popularity in the world of web development, primarily due to its scalability and efficiency. One of its defining characteristics is its single-threaded nature, which often sparks curiosity (and mostly confusion!) among developers. If Node.js is really single-threaded, how come high-traffic products such as Netflix, PayPal, and even Medium use it?

In this article, we’ll peek into the inner workings of Node.js, exploring its engine, threading capabilities, and the event loop.

Photo by author, edited on Canva.

The V8 Engine

At the heart of Node.js lies the V8 engine, written in C++ and developed by Google for the Chrome browser. V8 compiles and executes JavaScript source code on the call stack, handles memory allocation on the memory heap, and handles garbage collection.
The engine enables high-performance execution and utilizes just-in-time (JIT) compilation to optimize code execution speed.

Single-Threaded Architecture

Contrary to traditional multi-threaded server environments, Node.js operates on a single-threaded event-driven model. This means that it utilizes a single thread to handle all incoming requests and execute JavaScript code. While this may seem counterintuitive for handling concurrent operations, Node.js uses non-blocking I/O operations to optimize the utilization of resources.

When the code is executed, every call is popped onto the call stack. When it encounters an asynchronous request, Node.js registers a callback for it, offloads the operation to the underlying OS, and continues processing requests without waiting for the operation to complete. Once the call stack is empty, Node.js checks if there are any awaiting tasks to execute in what is called the Event Loop.

The Event Loop

The event loop is a mechanism that allows Node.js to coordinate blocking and non-blocking code. When the OS finishes executing an I/O task, the corresponding callback is pushed to the event loop for execution, which in turn is moved to the call stack once it’s empty. Let’s see an example of this:

Code example: non-blocking I/O operation.

As we can see in the code example above, we read a file in an async manner, providing a callback that prints the file contents to the console. We then log two other messages to the console. When running this code, note that the two log lines are outputted before the file content. The order of operations is as follows:

  • [call stack] read file content — offload I/O operation to the underlying OS.
  • [call stack] output log lines to console.
  • [call stack is empty] check for callbacks on the event loop. If the operation is done, push the callback function to the call stack.
  • [call stack] execute callback.

How is this happening? The event loop is indeed a loop that runs as long as our Node.js program is running. It contains 6 queues that are iterated over and checked for callbacks to push to the call stack. When an operation offloaded to the OS is completed, it waits in its relevant queue until the call stack is empty. When the call stack is empty, the engine iterates over the queues and pushes any callback from the queue to the call stack.

“Don’t Block the Main Thread!”

If you’re a JavaScript developer, you probably heard the above phrase before.

Callbacks are only executed when the call stack is empty, as synchronous code takes priority over asynchronous code. When a task occupies the main thread for an extended period, it prevents the event loop from processing other tasks, resulting in delays in handling incoming requests and bad performance. Here’s an example of blocking the main thread:

Code example: blocking the main thread — the callback is never executed.

As we see in the code example, a callback is registered to output the file contents to the console once the I/O operation is done, and then we run an endless loop (condition is always true). This will block the call stack as the code in line 11 will not finish running, and therefore the callback will not be executed.

To overcome this, offload any CPU-intensive operations to worker threads or utilize asynchronous APIs. Here’s an example of an asynchronous API to compress a file:

Code example: utilizing zlib’s asynchronous API — the sync equivalent would block the event loop until completed.

The above code shows the usage of zlib.gzip which is async, unlike the sync version which is explicitly stated in the function name. In the code we see the compression function accepts a callback, and the call back is run after the last log line, meaning this task did not block the main thread.

Note that not all functions have this naming convention, so check the implementation of functions used to run CPU-intensive tasks.

For CPU-intensive tasks that don’t have asynchronous APIs, Node.js offers the worker threads solution. Worker threads used in Node.js are not the equivalent of multi-threaded languages — in thread-per-request languages the operating system interrupts long-running threads to allow other threads to run, also called “fairness”. Since Node.js handles many requests in one thread, fairness is the responsibility of the developer.

This is why Node.js is a great use case for I/O-intensive applications and is not a good fit for CPU-intensive applications.

There’s much more to dive into, and I was contemplating whether to make a deeper dive into the vV8 engine, the event loop or even worker threads (though the last one seems like a misuse in many cases). Let me know if you’re interested in a deeper dive in!

Conclusion

Node.js’s single-threaded event-driven nature is non-blocking by design and offers a unique approach to server-side development by abstracting threads. While it may seem unconventional at first, this architecture provides excellent scalability and performance, particularly for I/O-heavy applications. By understanding the underlying mechanisms of Node.js, we can harness its full potential to build fast, responsive, and scalable web applications — take Netflix, for example, which relies on Node.js to serve millions of streaming requests daily, demonstrating its exceptional scalability and performance even under immense loads.

Now that you understand how Node.js works, and what its strengths and limitations are, you can use it to build your next I/O-intensive application — just remember not to block the main thread 😉

Additional resources

Here are some additional resources I recommend to better understand how Node.js works 😍

--

--

Miri Yehezkel

Autodidact senior software developer. Enjoys expanding and sharing knowledge, and passionate about encouraging growth in myself and others.