Profiling Node.js Applications: Step-by-Step Guide
Introduction to Profiling Profiling a Node.js app means analyzing CPU, memory, and runtime metrics to identify performance issues like high CPU usage, memory leaks, or slow functions. By profiling, we can detect bottlenecks and improve app speed, responsiveness, and scalability.
Tools for Profiling
While many third-party tools are available, Node.js also has a built-in profiler. The profiler uses the V8 engine, which samples the call stack periodically, recording data points like function calls and optimization events (e.g., JIT compiles).
Example App: User Authentication with Express
Let’s explore profiling with an Express app that has two routes:
/newUser
– Adds a new user with a hashed password./auth
– Authenticates a user’s login request.
New User Handler
app.get('/newUser', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || users[username]) return res.sendStatus(400);
const salt = crypto.randomBytes(128).toString('base64');
const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');
users[username] = { salt, hash };
res.sendStatus(200);
});
Authentication Handler
app.get('/auth', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || !users[username]) return res.sendStatus(400);
const { salt, hash } = users[username];
const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512');
if (crypto.timingSafeEqual(hash, encryptHash)) res.sendStatus(200);
else res.sendStatus(401);
});
Note: This example is for educational purposes. In production, use established libraries for user authentication.
Running the Profiler
Assume the app is deployed, and users report latency issues. To profile it:
- Run the app with the profiler:
NODE_ENV=production node --prof app.js
2. Simulate load with ApacheBench (ab):
curl -X GET "http://localhost:8080/newUser?username=matt&password=password"
ab -k -c 20 -n 250 "http://localhost:8080/auth?username=matt&password=password"
Output Example:
Concurrency Level: 20
Time taken for tests: 46.932 seconds
Requests per second: 5.33 [#/sec]
Time per request: 3754.556 [ms] (mean)
The app handles only ~5 requests per second, with an average request time of 4 seconds, indicating a bottleneck.
Analyzing the Profiler Output
After running with --prof
, a log file (e.g., isolate-0x...-v8.log
) is generated. To analyze it:
- Process the file:
node --prof-process isolate-0x...-v8.log > processed.txt
2. Open processed.txt
in a text editor. The summary shows time spent by each part of the code:
[Summary]:
ticks total nonlib name
79 0.2% 0.2% JavaScript
36703 97.2% 99.2% C++
7 0.0% 0.0% GC
Most time (97%) is spent in C++, meaning the core work is outside JavaScript.
3. In the [C++]
section, specific functions that consume the most CPU time are shown:
[C++]:
ticks total nonlib name
19557 51.8% 52.9% node::crypto::PBKDF2
4510 11.9% 12.2% _sha1_block_data_order
Observation: The PBKDF2 function, which hashes passwords, takes over half of the CPU time.
4. To further understand, check the [Bottom up (heavy) profile]
section, which shows the functions calling PBKDF2:
ticks parent name
19557 51.8% node::crypto::PBKDF2
19557 100.0% LazyCompile: *pbkdf2 crypto.js:557
Optimizing the Code
The profiling reveals that synchronous password hashing (PBKDF2) blocks the event loop. Switching to asynchronous hashing will allow other requests to proceed while a hash is computed.
Updated Asynchronous Handler
app.get('/auth', (req, res) => {
let username = req.query.username || '';
const password = req.query.password || '';
username = username.replace(/[!@#$%^&*]/g, '');
if (!username || !password || !users[username]) return res.sendStatus(400);
crypto.pbkdf2(password, users[username].salt, 10000, 512, 'sha512', (err, hash) => {
if (users[username].hash.toString() === hash.toString()) res.sendStatus(200);
else res.sendStatus(401);
});
});
Re-Testing Performance
Run the app again with ab
. The results should now show a significant performance improvement.
Updated Output Example:
Concurrency Level: 20
Time taken for tests: 12.846 seconds
Requests per second: 19.46 [#/sec]
With asynchronous hashing, the app handles about 20 requests per second — a 4x increase — and reduces average request time to ~1 second.
Conclusion
By analyzing the profiler output, we identified a CPU-heavy function and optimized it with asynchronous code, improving app responsiveness. The Node.js profiler can be a powerful tool for understanding and optimizing your application’s performance.
Next Read :