Node.js Performance Hooks and Measurement APIs

Lakin Mohapatra
7 min readOct 13, 2024

--

For Node.js developers, understanding and optimizing application performance is crucial for delivering exceptional user experiences. This article talks about Node.js performance hooks and measurement APIs, providing you with the tools and knowledge to identify bottlenecks, measure execution times, and enhance your application’s overall performance.

Understanding the Need for Performance Measurement

Before we dive into the specifics of Node.js performance tools, let’s consider why performance measurement is essential:

  • Slow applications frustrate users and can lead to decreased engagement.
  • Optimized code uses server resources more efficiently, reducing costs.
  • Well-performing applications are easier to scale as user demand grows.
  • Fast, responsive applications give businesses an advantage in the market.

With these factors in mind, let’s explore how Node.js equips developers to measure and optimize performance.

Node.js Performance API: A Powerful Toolkit

Node.js provides a built-in Performance API that offers high-resolution timing and performance measurement capabilities. This API is part of the perf_hooks module and includes several useful functions and objects.

Key Components of the Performance API

  1. performance.now(): Provides a high-resolution timestamp in milliseconds.
  2. performance.mark(): Creates a named timestamp in the application’s timeline.
  3. performance.measure(): Calculates the duration between two marks.

Let’s look at how to use these components effectively.

Practical Examples: Measuring Execution Time

Basic Timing with performance.now()

Here’s a simple example of measuring execution time:

const { performance } = require('node:perf_hooks');
const start = performance.now();
// Simulate some work
for (let i = 0; i < 1000000; i++) {
// Some operation
}
const end = performance.now();
console.log(`Execution time: ${end - start} milliseconds`);

This method provides more precision than using Date.now(), especially for short-duration operations.

Using Marks and Measures

For more complex scenarios, performance.mark() and performance.measure() offer greater flexibility:

const { performance } = require('node:perf_hooks');

performance.mark('start');
// Simulate some work
for (let i = 0; i < 1000000; i++) {
// Some operation
}
performance.mark('end');
const measure = performance.measure('loop', 'start', 'end');
console.log(`Loop execution time: ${measure.duration} milliseconds`);

This approach allows you to create named checkpoints in your code and measure the duration between them.

Let’s explore a more complex scenario where we need to measure different parts of our code:

const { performance } = require('node:perf_hooks');

async function complexOperation() {
performance.mark('start');
performance.mark('phase1Start');
await simulateAsyncWork(200);
performance.mark('phase1End');
performance.mark('phase2Start');
await simulateAsyncWork(150);
performance.mark('phase2End');
performance.mark('phase3Start');
await simulateAsyncWork(100);
performance.mark('phase3End');
performance.mark('end');
performance.measure('Total time', 'start', 'end');
performance.measure('Phase 1', 'phase1Start', 'phase1End');
performance.measure('Phase 2', 'phase2Start', 'phase2End');
performance.measure('Phase 3', 'phase3Start', 'phase3End');
const measures = performance.getEntriesByType('measure');
measures.forEach(measure => {
console.log(`${measure.name}: ${measure.duration.toFixed(2)} ms`);
});
}
function simulateAsyncWork(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
complexOperation();

This example demonstrates how to use performance.mark() to create timestamps and performance.measure() to calculate durations between marks.

Advanced Usage: Optimizing Database Queries and Sorting Operations

Let’s apply these concepts to a more realistic scenario involving database queries and data sorting:

const { performance } = require('node:perf_hooks');

async function fetchAndSortData(querySize) {
performance.mark('fetch_start');
const data = await fetchDataFromDB(querySize);
performance.mark('fetch_end');

performance.mark('sort_start');
const sortedData = sortData(data);
performance.mark('sort_end');

const fetchTime = performance.measure('fetch_time', 'fetch_start', 'fetch_end');
const sortTime = performance.measure('sort_time', 'sort_start', 'sort_end');

console.log(`Fetch time: ${fetchTime.duration} ms`);
console.log(`Sort time: ${sortTime.duration} ms`);

return sortedData;
}
// Usage
fetchAndSortData(10000).then(() => console.log('Operation complete'));

This example demonstrates how to measure both database fetch time and sorting time separately, allowing you to identify which operation might be a bottleneck as your data size grows.

Optimizing an API Endpoint

Now, let’s apply these concepts to a real-world scenario: optimizing an API endpoint that fetches and processes user data.

const { performance } = require('node:perf_hooks');

async function getUserData(userId) {
performance.mark('fetchStart');
const userData = await fetchUserFromDatabase(userId);
performance.mark('fetchEnd');

performance.mark('processStart');
const processedData = processUserData(userData);
performance.mark('processEnd');

performance.mark('formatStart');
const formattedResponse = formatResponse(processedData);
performance.mark('formatEnd');

performance.measure('fetchTime', 'fetchStart', 'fetchEnd');
performance.measure('processTime', 'processStart', 'processEnd');
performance.measure('formatTime', 'formatStart', 'formatEnd');

const measures = performance.getEntriesByType('measure');
measures.forEach(measure => {
console.log(`${measure.name}: ${measure.duration.toFixed(2)} ms`);
});

return formattedResponse;
}
// Simulated functions
async function fetchUserFromDatabase(userId) {
await simulateAsyncWork(300); // Simulate DB query
return { id: userId, name: 'John Doe', email: 'john@example.com' };
}
function processUserData(userData) {
simulateWork(150); // Simulate CPU-intensive processing
return { ...userData, lastAccess: new Date() };
}
function formatResponse(data) {
return JSON.stringify(data);
}
// Helper functions
function simulateWork(duration) {
const start = Date.now();
while (Date.now() - start < duration) {
// Simulate CPU-intensive work
}
}
function simulateAsyncWork(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
getUserData(123).then(result => {
console.log('Formatted response:', result);
});

This example shows how to break down an API endpoint into measurable components, helping you identify which part of the process might be causing performance issues.

Leveraging Worker Threads for CPU-Intensive Tasks

For CPU-bound operations that might block the main thread, Node.js offers the worker_threads module. Here's how you can use it in conjunction with performance measurements:

const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');
const { performance } = require('node:perf_hooks');

if (isMainThread) {
const worker = new Worker(__filename, { workerData: { size: 1000000 } });

performance.mark('worker_start');

worker.on('message', (result) => {
performance.mark('worker_end');
const measure = performance.measure('worker_time', 'worker_start', 'worker_end');
console.log(`Worker execution time: ${measure.duration} ms`);
console.log(`Sorted array size: ${result.length}`);
});
} else {
const { size } = workerData;
const data = Array.from({ length: size }, () => Math.random());
const sortedData = data.sort((a, b) => a - b);
parentPort.postMessage(sortedData);
}

This example offloads a large sorting operation to a worker thread, measuring the total execution time from the main thread’s perspective.

Monitoring Memory Usage

Memory usage is another critical aspect of application performance. Here’s how you can track memory usage over time:

const { performance, PerformanceObserver } = require('node:perf_hooks');

const obs = new PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
console.log(`${entry.name}: ${Math.round(entry.value / 1024 / 1024 * 100) / 100} MB`);
});
});
obs.observe({ entryTypes: ['measure'] });
function checkMemoryUsage() {
const memoryUsage = process.memoryUsage();
performance.mark('memoryStart');
Object.entries(memoryUsage).forEach(([key, value]) => {
performance.measure(`Memory ${key}`, 'memoryStart', { detail: value });
});
}
// Simulate an application that gradually uses more memory
let data = [];
function simulateMemoryUsage() {
data.push(new Array(10000).fill('x'));
checkMemoryUsage();
}
// Check memory usage every second
setInterval(simulateMemoryUsage, 1000);

This script simulates an application that gradually uses more memory and logs the usage every second, helping you identify memory leaks or unexpected growth in memory consumption.

Measuring Network Request Performance

In many Node.js applications, network requests are a common source of performance bottlenecks. Here’s how you can measure the performance of HTTP requests:

const { performance } = require('node:perf_hooks');
const https = require('https');

function measureHttpRequest(url) {
return new Promise((resolve, reject) => {
performance.mark('requestStart');
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
performance.mark('requestEnd');
performance.measure('requestDuration', 'requestStart', 'requestEnd');
const measure = performance.getEntriesByName('requestDuration')[0];
console.log(`Request to ${url} took ${measure.duration.toFixed(2)} ms`);
resolve(data);
});
}).on('error', (err) => {
reject(err);
});
});
}
// Usage
measureHttpRequest('https://api.github.com/users/github')
.then(data => console.log('Received data length:', data.length))
.catch(err => console.error('Error:', err));

This example demonstrates how to measure the total time taken for an HTTP request, including network latency and data transfer time.

Profiling Database Queries

Database operations can often be a performance bottleneck. Here’s an example of how to profile database queries using a popular ORM like Sequelize:

const { performance } = require('node:perf_hooks');
const { Sequelize, Model, DataTypes } = require('sequelize');

const sequelize = new Sequelize('sqlite::memory:');
class User extends Model {}
User.init({
username: DataTypes.STRING,
birthday: DataTypes.DATE
}, { sequelize, modelName: 'user' });
async function profileDatabaseOperations() {
await sequelize.sync();
performance.mark('insertStart');
await User.create({
username: 'janedoe',
birthday: new Date(1980, 6, 20)
});
performance.mark('insertEnd');
performance.measure('Insert Operation', 'insertStart', 'insertEnd');
performance.mark('findStart');
const users = await User.findAll();
performance.mark('findEnd');
performance.measure('Find All Operation', 'findStart', 'findEnd');
const measures = performance.getEntriesByType('measure');
measures.forEach(measure => {
console.log(`${measure.name}: ${measure.duration.toFixed(2)} ms`);
});
}
profileDatabaseOperations().catch(console.error);

This example shows how to measure the performance of database insert and select operations, which can help identify slow queries.

Measuring File I/O Operations

File I/O can be a significant performance factor, especially for applications that handle large files. Here’s how to measure file read and write operations:

const { performance } = require('node:perf_hooks');
const fs = require('fs').promises;

async function measureFileOperations() {
const data = 'A'.repeat(1024 * 1024); // 1MB of data
performance.mark('writeStart');
await fs.writeFile('test.txt', data);
performance.mark('writeEnd');
performance.measure('File Write', 'writeStart', 'writeEnd');
performance.mark('readStart');
const readData = await fs.readFile('test.txt', 'utf8');
performance.mark('readEnd');
performance.measure('File Read', 'readStart', 'readEnd');
const measures = performance.getEntriesByType('measure');
measures.forEach(measure => {
console.log(`${measure.name}: ${measure.duration.toFixed(2)} ms`);
});
await fs.unlink('test.txt'); // Clean up
}
measureFileOperations().catch(console.error);

This example demonstrates how to measure the time taken for file write and read operations, which can be crucial for applications that deal with large amounts of data.

Monitoring Event Loop Lag

Event loop lag can indicate when your Node.js application is overloaded. Here’s a way to monitor it:

const { performance, PerformanceObserver } = require('node:perf_hooks');

let lastCheck = performance.now();
function checkEventLoopLag() {
const now = performance.now();
const lag = now - lastCheck;
lastCheck = now;
performance.mark('lagStart');
performance.measure('Event Loop Lag', 'lagStart', { detail: lag });
setTimeout(checkEventLoopLag, 1000); // Check every second
}
const obs = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
console.log(`Event Loop Lag: ${entry.detail.toFixed(2)} ms`);
});
obs.observe({ entryTypes: ['measure'] });
checkEventLoopLag();
// Simulate some work to cause event loop lag
function simulateWork() {
const start = performance.now();
while (performance.now() - start < 100) {
// Simulate 100ms of synchronous work
}
setTimeout(simulateWork, Math.random() * 1000);
}
simulateWork();

This script monitors the event loop lag, which can help identify when your application is becoming unresponsive due to long-running synchronous operations.

Custom Performance Metrics

Sometimes, you might want to measure application-specific metrics. Here’s how you can create and use custom performance metrics:

const { performance, PerformanceObserver } = require('node:perf_hooks');

// Create a custom performance metric
const customMetric = new performance.PerformanceEntry('CustomMetric', 'measure', performance.now(), 0);
function trackCustomMetric(value) {
performance.mark('customStart');
performance.measure('CustomMetric', 'customStart', { detail: value });
}
const obs = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
console.log(`Custom Metric: ${entry.name}, Value: ${entry.detail}`);
});
obs.observe({ entryTypes: ['measure'] });
// Simulate tracking a custom metric (e.g., active users)
function simulateActiveUsers() {
const activeUsers = Math.floor(Math.random() * 1000);
trackCustomMetric(activeUsers);
setTimeout(simulateActiveUsers, 5000); // Update every 5 seconds
}
simulateActiveUsers();

This example shows how to create and track custom performance metrics, which can be useful for monitoring application-specific indicators like active users, request rates, or any other custom value you want to track over time.

Final Thoughts

Mastering Node.js performance hooks and measurement APIs is crucial for developing high-performance applications. By understanding and applying these tools, you can identify bottlenecks, optimize critical paths, and deliver faster, more efficient Node.js applications.

Performance optimization is an ongoing process. Regularly measure, analyze, and refine your application to ensure it continues to meet and exceed performance expectations as it grows and evolves.

Happy coding, and may your Node.js applications be ever swift and efficient!

--

--

Lakin Mohapatra
Lakin Mohapatra

Written by Lakin Mohapatra

Software Engineer | Hungry coder | Proud Indian | Cyber Security Researcher | Blogger | Architect (web2 + web 3)

No responses yet