CPU-bound tasks can grind your Node.js applications to a standstill, frustrating users and driving them away. You must master the art of profiling your application to pinpoint bottlenecks, and implement effective strategies to resolve any issues.
In this guide, we'll explore various techniques to help you identify and fix the root causes of your Node.js performance issues.
Let's get started!
Prerequisites
Before proceeding with the article, ensure that you have the most recent LTS of
Node.js and npm
installed on your computer.
We'll use autocannon to run load tests on various server endpoints, so install it globally on your machine:
To follow along with the code samples, clone this repository and install the dependencies accordingly:
Node.js Architecture: A Quick Overview
Node.js operates on an event-driven architecture, handling multiple client requests concurrently without creating separate threads for each connection. It achieves this by being non-blocking, allowing I/O operations like file operations and database queries to be processed asynchronously. When events occur, registered callbacks are executed, ensuring efficient handling of various tasks.
However, Node.js isn't suitable for CPU-bound tasks like heavy calculations or image processing. These tasks rely on the speed of a computer's CPU, and Node.js, with its single-threaded event loop, can only execute one JavaScript code at a time. As a result, CPU-bound tasks can block the main thread, causing significant performance problems, as we'll explore further in the next section.
Demonstrating a CPU-bound Task in Node.js
To illustrate a CPU-bound task, let's use the classic example of calculating a Fibonacci number from a specific position in the Fibonacci sequence.
This sequence starts with 0 and 1, where each subsequent number is the sum of
the two preceding ones, following the relation F(n) = F(n−1) + F(n−2)
with
seed values F(0) = 0, F(1) = 1
. Here are the first 20 Fibonacci numbers:
Calculating the nth Fibonacci number using a recursive algorithm is straightforward:
However, this algorithm's time complexity is O(2^n)
, indicating exponential
growth for the input size (n) (meaning that as n
increases, the
algorithm becomes significantly slower).
Bear in mind that there are much more efficient algorithms for calculating the Fibonacci number. This example only serves to illustrate the problem with CPU-bound tasks in Node.js.
Testing the CPU-bound Task
In the src/server.js
file, there's a /fibonacci-recursive/:n
route for
calculating the Fibonacci number at position n
:
You can test it by starting the server on port 3000:
Once the server has started, send a request to find the 30th Fibonacci number.
You can prepend the time
command to find out approximately how long the
request takes:
You should observe the result fairly quickly (on my laptop, it took 10 milliseconds):
When the number starts to increase, even only slightly, the response time grows by many orders of magnitude. On my machine, finding the 40th and 45th Fibonacci numbers took 810ms and 9s, respectively.
Impact on the Event Loop
Aside from the fact that the results take a progressively long time to compute, each request also completely blocks the event loop, preventing any other task from executing until its completion.
To establish baseline behavior, use autocannon
to send some load to the /
route. Ensure that the server isn't processing any other requests before
running the command below:
On my machine, I observed a throughput of almost 49k requests per second:
Now, block the event loop by calculating the 50th Fibonacci number as follows. This request will take a long time to complete (about 123 seconds on my machine):
While the result is computing (and the event loop is blocked), in a different
terminal, use autocannon
to send load to the /
once again:
You will observe that the throughput drops dramatically to zero requests per second due to the ongoing Fibonacci computation. This clearly demonstrates how devastating blocking the event loop can be to Node.js performance.
Profiling your Node.js Application for Bottlenecks
When your application's performance starts to lag, pinpointing the exact cause of slowdowns can be a challenge. To effectively troubleshoot such issues, the first step is to profile your code and identify bottlenecks.
In this section, we'll explore three main approaches for profiling Node.js applications:
- Using the built-in Node.js profiler
- Using Chrome DevTools
- Using the Inspector module for on-demand profiling in production
1. Using the Built-in Node.js Profiler
The first approach we'll consider requires no external tools or libraries. It
involves using the built-in sample-based profiler built into Node.js, which is
enabled through the --prof
command-line option. Once the profiler is turned
on, it records stacks of both JavaScript and C/C++ code into a file in the
current working directory.
First, enable the profiler using the --prof
command-line option when starting
your Node.js server:
Once the server is running, simulate traffic to the /fibonacci-recursive/:n
route through autocannon
as shown below:
On my machine, the server processed an average of 138.7 requests per second:
The profiler records data into a file in your project directory. You will notice
a new tick log file named isolate-0x<number>-v8.log
. This file is not meant to
be consumed by humans, but must be processed further to generate
human-readable data.
To process this log data, use the built-in Node.js tick processor:
This creates a new processed.txt
file with human-readable profiling data in
your project root. You can view its content by opening it up in your text
editor. The file is broken up into several sections, each with its own table.
Let's delve into the processed data to identify bottlenecks, starting with the
[Summary]
section:
This summary indicates that approximately 84% of the samples occurred in JavaScript code, directing your attention to the JavaScript functions for investigation.
With this in mind, navigate to the [Javascript]
section of the file to see
which JavaScript functions are taking up the most time:
In a real-world application, you might do a lot of processing in several functions
to handle user requests. But here, the calcFiboRecursive()
function is
(predictably) where most time is spent, making it the clear target for
optimization.
2. Using Chrome DevTools
A second approach to profiling your Node.js applications is to use Chrome
DevTools to collect performance data. You need to start your server using the
--inspect
flag to get started:
Once the server is running, open Google Chrome or any other Chromium-based browser,
and type chrome://inspect
in the address bar.
Find the inspect link for your Node.js script and click on it to bring up a new DevTools window for debugging your Node.js app. Head over to the Performance tab and click on the record button to start profiling your code.
Head back to your terminal, and run autocannon
against the
/fibonacci-recursive/:n
route once more:
Once the command finishes, return to your DevTools window and click the Stop button to finish the profiling. You will immediately observe a detailed performance profile of your program:
The Main section displays a flame chart representing main thread activity in your program. The horizontal axis represents time, while the vertical axis represents the call stack. You can zoom in to get a more detailed view:
In this view, the events on top cause the events below them, and the width of each event
indicates its duration (the wider it is, the longer the event runs). The frames under
parserOnHeadersComplete
encompass routing logic, parameter processing, and,
finally, lead to the request handler ((anonymous)
). This handler calls the
calcFiboRecursive()
function multiple times due to its recursive nature,
clearly visible in the graph.
Clicking the (anonymous)
frame and switching to the Bottom-Up tab below
lists the various resulting function calls and orders them according to their
duration:
3. Using the Inspector Module for On-Demand Profiling in Production
The two profiling approaches above are useful ways to profile your code in a development environment. However, since performance issues are often first observed in production, it's crucial to set up on-demand profiling in a way that won't affect runtime performance.
The approach we'll discuss here combines the strengths of both methods, using built-in Node.js APIs to capture profiling data and then analyzing the data using Chrome DevTools.
The Inspector API provides the ability to interact with the V8 inspector programmatically. You can use it to collect profiling data from your application like this:
This file sets up an on-demand CPU profiling mechanism for the Node.js application through the following two functions:
startCPUProfile()
connects to the inspector session and enables the CPU profiler by sending appropriate commands using the inspector protocol.stopCPUProfile()
stops the CPU profiler, retrieves the profiling data, and writes it to a file namedprofile.cpuprofile
.
The program is also set up to listen for the SIGUSR1
and SIGUSR2
signals.
The former triggers the collection of the CPU profile, while the latter stops
CPU profiling and saves the profiling data to a file for analysis.
You can restart your server with npm start
, then trigger CPU profiling by
sending the SIGUSR1
signal to the process through the kill
command. Ensure
you replace <process_id>
with the pid
value found in the server startup log:
If the above command exits successfully, it indicates that profiling has
started. You can now use autocannon
to send some load to the
/fibonacci-recursive/:n
route as before. Once that's done, send the SIGUSR2
signal to stop the profiling and write the data to a file:
You should see a new profile.cpuprofile
file in your project directory
which contains the collected profiling information. To analyze the data, open
Chrome DevTools and click the Load profile button in the Performance tab:
Once the file is loaded, you will see the flame graph as before. Analyze it to identify performance bottlenecks, just as described in the previous section.
With this approach, you can trigger CPU profiling on demand, in real-time, without interrupting server operations. In a production environment, consider setting up a different mechanism (e.g., an HTTP endpoint) to trigger profiling and automatically upload the profile data to a secure location for convenient retrieval and analysis.
Wrapping Up
In this article, we explored the challenges associated with managing CPU-bound tasks in Node.js and their substantial influence on runtime efficiency. You also learned several methods to profile your code and identify performance bottlenecks.
In the upcoming and concluding second part, our focus will shift to specific techniques you can employ to enhance the performance of CPU-bound tasks in Node.js.
Until next time, thanks for reading!
P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.