Performance Optimization in Node.js
Introduction to Node.js Performance
Performance optimization is crucial for building scalable Node.js applications. Understanding how the event loop works, identifying bottlenecks, and applying optimization techniques can dramatically improve your application's throughput and response times.
Key Principle: Node.js is single-threaded with non-blocking I/O. CPU-intensive tasks block the event loop, while I/O operations are handled asynchronously. Optimize accordingly.
Understanding the Event Loop
The event loop is the heart of Node.js. Understanding its phases helps identify performance issues:
// Event Loop Phases:
// 1. Timers (setTimeout, setInterval)
// 2. Pending callbacks (I/O callbacks deferred to next loop)
// 3. Idle, prepare (internal use)
// 4. Poll (retrieve new I/O events)
// 5. Check (setImmediate callbacks)
// 6. Close callbacks (socket.on('close'))
// Visualize event loop
const eventLoopMonitor = () => {
const start = Date.now();
let iterations = 0;
const check = () => {
iterations++;
const elapsed = Date.now() - start;
if (elapsed < 1000) {
setImmediate(check);
} else {
console.log(`Event loop: ${iterations} iterations/sec`);
}
};
check();
};
// Measure event loop lag
const eventLoopLag = require('event-loop-lag');
const lag = eventLoopLag(1000); // Check every second
setInterval(() => {
console.log(`Event loop lag: ${lag()}ms`);
}, 5000);
// Blocking vs Non-blocking
// BAD - Blocks event loop
function calculateSum(n) {
let sum = 0;
for (let i = 0; i < n; i++) {
sum += i;
}
return sum;
}
// GOOD - Use worker threads for CPU-intensive tasks
const { Worker } = require('worker_threads');
function calculateSumAsync(n) {
return new Promise((resolve, reject) => {
const worker = new Worker(`
const { parentPort, workerData } = require('worker_threads');
let sum = 0;
for (let i = 0; i < workerData.n; i++) {
sum += i;
}
parentPort.postMessage(sum);
`, { eval: true, workerData: { n } });
worker.on('message', resolve);
worker.on('error', reject);
});
}
Profiling Node.js Applications
Use built-in profiling tools to identify performance bottlenecks:
// 1. Built-in Node.js Profiler
// Start with --prof flag
node --prof app.js
// Generate readable report
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt
// 2. Chrome DevTools
// Start with --inspect flag
node --inspect app.js
// Open chrome://inspect in Chrome
// 3. clinic.js - Comprehensive profiling
npm install -g clinic
// Doctor - detect event loop issues
clinic doctor -- node app.js
// Bubbleprof - async operations
clinic bubbleprof -- node app.js
// Flame - CPU profiling
clinic flame -- node app.js
// 4. Custom performance monitoring
const { performance, PerformanceObserver } = require('perf_hooks');
// Measure function execution time
performance.mark('start-operation');
await someExpensiveOperation();
performance.mark('end-operation');
performance.measure('operation', 'start-operation', 'end-operation');
const obs = new PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
});
});
obs.observe({ entryTypes: ['measure'] });
Memory Leak Detection
Memory leaks occur when unused memory isn't released. Detect and fix them:
// 1. Monitor memory usage
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)}MB`, // Resident Set Size
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)}MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)}MB`,
external: `${Math.round(used.external / 1024 / 1024)}MB`
});
// 2. Heap snapshots for leak detection
const v8 = require('v8');
const fs = require('fs');
function takeHeapSnapshot() {
const filename = `heap-${Date.now()}.heapsnapshot`;
const heapSnapshot = v8.writeHeapSnapshot(filename);
console.log(`Heap snapshot written to ${heapSnapshot}`);
}
// Take snapshots at intervals and compare
setInterval(takeHeapSnapshot, 60000);
// 3. Common memory leak patterns
// BAD - Global variable accumulation
let cache = [];
function addToCache(data) {
cache.push(data); // Never cleared!
}
// GOOD - Use proper cache with size limits
const LRU = require('lru-cache');
const cache = new LRU({
max: 500,
maxAge: 1000 * 60 * 60 // 1 hour
});
// BAD - Event listener leaks
function setupListeners() {
const emitter = new EventEmitter();
setInterval(() => {
emitter.on('data', handleData); // New listener every second!
}, 1000);
}
// GOOD - Remove listeners or use once()
function setupListeners() {
const emitter = new EventEmitter();
emitter.once('data', handleData); // Automatically removed
// Or: emitter.removeListener('data', handleData);
}
// BAD - Closure retaining large objects
function createClosure() {
const largeObject = new Array(1000000);
return function() {
console.log(largeObject.length); // Keeps largeObject in memory
};
}
// GOOD - Only capture what you need
function createClosure() {
const largeObject = new Array(1000000);
const length = largeObject.length;
return function() {
console.log(length); // Only stores number
};
}
Memory Leak Warning: Event listeners, global caches, and closures are common sources of memory leaks. Always clean up resources and use weak references when appropriate.
Database Query Optimization
Database queries are often the biggest performance bottleneck:
// 1. Use indexes
// Create indexes on frequently queried fields
await db.schema.table('users', (table) => {
table.index('email');
table.index('created_at');
table.index(['status', 'created_at']); // Composite index
});
// 2. Select only needed columns
// BAD
const users = await db('users').select('*');
// GOOD
const users = await db('users').select('id', 'name', 'email');
// 3. Use pagination
// BAD - Loads all records
const allUsers = await db('users').select();
// GOOD - Paginate results
const page = 1;
const perPage = 20;
const users = await db('users')
.select()
.limit(perPage)
.offset((page - 1) * perPage);
// 4. Avoid N+1 queries
// BAD - N+1 query problem
const posts = await db('posts').select();
for (const post of posts) {
post.author = await db('users').where({ id: post.user_id }).first();
}
// GOOD - Use JOIN or eager loading
const posts = await db('posts')
.select('posts.*', 'users.name as author_name')
.join('users', 'posts.user_id', 'users.id');
// 5. Use connection pooling
const pool = new Pool({
host: 'localhost',
database: 'mydb',
max: 20, // Maximum connections
min: 5, // Minimum connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
// 6. Implement query result caching
const cache = new Map();
async function getCachedUser(userId) {
const cacheKey = `user:${userId}`;
// Check cache
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
// Query database
const user = await db('users').where({ id: userId }).first();
// Cache result
cache.set(cacheKey, user);
// Expire after 5 minutes
setTimeout(() => cache.delete(cacheKey), 300000);
return user;
}
Caching Strategies
Implement multi-layer caching for optimal performance:
// 1. In-memory caching
const NodeCache = require('node-cache');
const cache = new NodeCache({
stdTTL: 600, // Default TTL: 10 minutes
checkperiod: 120, // Check for expired keys every 2 minutes
useClones: false // Don't clone objects (faster but mutable)
});
// Set cache
cache.set('key', 'value', 300); // 5 minutes
// Get cache
const value = cache.get('key');
// Delete cache
cache.del('key');
// 2. Redis caching
const redis = require('redis');
const client = redis.createClient();
await client.connect();
// Set with expiration
await client.set('user:123', JSON.stringify(userData), {
EX: 3600 // 1 hour
});
// Get
const cached = await client.get('user:123');
const userData = cached ? JSON.parse(cached) : null;
// 3. Multi-layer caching
class CacheManager {
constructor() {
this.memCache = new NodeCache({ stdTTL: 300 });
this.redisClient = redis.createClient();
}
async get(key) {
// Level 1: In-memory cache (fastest)
let value = this.memCache.get(key);
if (value) {
console.log('Memory cache hit');
return value;
}
// Level 2: Redis cache (fast)
value = await this.redisClient.get(key);
if (value) {
console.log('Redis cache hit');
value = JSON.parse(value);
this.memCache.set(key, value); // Populate memory cache
return value;
}
console.log('Cache miss');
return null;
}
async set(key, value, ttl = 3600) {
// Store in both layers
this.memCache.set(key, value, Math.min(ttl, 300)); // Max 5 min in memory
await this.redisClient.set(key, JSON.stringify(value), { EX: ttl });
}
async delete(key) {
this.memCache.del(key);
await this.redisClient.del(key);
}
}
// 4. HTTP caching with ETags
const crypto = require('crypto');
app.get('/api/data', async (req, res) => {
const data = await fetchData();
const etag = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
// Check if client has cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Not Modified
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=300'); // 5 minutes
res.json(data);
});
Pro Tip: Use cache warming - preload frequently accessed data into cache during startup to avoid cold cache scenarios.
Compression
Compress responses to reduce bandwidth and improve load times:
// HTTP compression
const compression = require('compression');
app.use(compression({
level: 6, // Compression level (0-9)
threshold: 1024, // Minimum size to compress (bytes)
filter: (req, res) => {
// Don't compress if client doesn't accept encoding
if (req.headers['x-no-compression']) {
return false;
}
// Use compression default filter
return compression.filter(req, res);
}
}));
// Stream compression for large files
const zlib = require('zlib');
const fs = require('fs');
app.get('/download/large-file', (req, res) => {
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Content-Type', 'application/json');
fs.createReadStream('large-file.json')
.pipe(zlib.createGzip())
.pipe(res);
});
Asynchronous Optimization
Optimize asynchronous operations for better performance:
// 1. Parallel vs Sequential execution
// BAD - Sequential (slow)
async function fetchAllData() {
const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();
return { users, posts, comments };
}
// GOOD - Parallel (fast)
async function fetchAllData() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
return { users, posts, comments };
}
// 2. Limit concurrent operations
const pLimit = require('p-limit');
const limit = pLimit(5); // Max 5 concurrent operations
const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const users = await Promise.all(
userIds.map(id => limit(() => fetchUser(id)))
);
// 3. Stream processing for large datasets
const { Transform } = require('stream');
// Process large file without loading into memory
const processStream = new Transform({
objectMode: true,
transform(chunk, encoding, callback) {
// Process chunk
const processed = processData(chunk);
callback(null, processed);
}
});
fs.createReadStream('large-file.json')
.pipe(JSONStream.parse('*'))
.pipe(processStream)
.pipe(fs.createWriteStream('output.json'));
// 4. Async iteration for large collections
async function* fetchUsersInBatches(batchSize = 100) {
let offset = 0;
let hasMore = true;
while (hasMore) {
const users = await db('users')
.limit(batchSize)
.offset(offset);
if (users.length === 0) {
hasMore = false;
} else {
yield users;
offset += batchSize;
}
}
}
// Process in batches
for await (const batch of fetchUsersInBatches()) {
await processBatch(batch);
}
Cluster Mode for Multi-Core Utilization
Use cluster mode to take advantage of multi-core CPUs:
// cluster.js
const cluster = require('cluster');
const os = require('os');
const numCPUs = os.cpus().length;
if (cluster.isMaster) {
console.log(`Master process ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// Restart worker if it dies
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died`);
console.log('Starting a new worker');
cluster.fork();
});
// Graceful restart
process.on('SIGUSR2', () => {
const workers = Object.values(cluster.workers);
const restartWorker = (workerIndex) => {
const worker = workers[workerIndex];
if (!worker) return;
worker.on('exit', () => {
if (!worker.exitedAfterDisconnect) return;
console.log(`Worker ${worker.process.pid} exited`);
cluster.fork().on('listening', () => {
restartWorker(workerIndex + 1);
});
});
worker.disconnect();
};
restartWorker(0);
});
} else {
// Worker processes
require('./app');
console.log(`Worker ${process.pid} started`);
}
// Use PM2 instead (recommended)
// ecosystem.config.js
module.exports = {
apps: [{
name: 'my-app',
script: './app.js',
instances: 'max', // Use all CPU cores
exec_mode: 'cluster'
}]
};
Optimizing JSON Operations
JSON parsing/stringifying can be expensive for large objects:
// 1. Use fast-json-stringify (faster serialization)
const fastJson = require('fast-json-stringify');
const stringify = fastJson({
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
email: { type: 'string' }
}
});
const json = stringify({ id: 1, name: 'John', email: 'john@example.com' });
// 2. Stream large JSON
const JSONStream = require('JSONStream');
app.get('/api/large-dataset', (req, res) => {
res.setHeader('Content-Type', 'application/json');
db('users')
.stream()
.pipe(JSONStream.stringify())
.pipe(res);
});
// 3. Avoid repeated JSON.stringify
// BAD
app.use((req, res, next) => {
console.log(JSON.stringify(req.body));
logger.info(JSON.stringify(req.body));
cache.set('body', JSON.stringify(req.body));
next();
});
// GOOD
app.use((req, res, next) => {
const bodyStr = JSON.stringify(req.body);
console.log(bodyStr);
logger.info(bodyStr);
cache.set('body', bodyStr);
next();
});
Exercise: Performance Audit and Optimization
Take an existing Node.js application and perform a complete performance audit:
- Profile the application using clinic.js and identify bottlenecks
- Detect and fix any memory leaks using heap snapshots
- Optimize database queries (add indexes, fix N+1 queries)
- Implement multi-layer caching (memory + Redis)
- Add compression middleware
- Convert sequential operations to parallel where possible
- Set up cluster mode or PM2
- Measure before/after performance (response times, throughput, memory usage)
Document your findings and improvements with metrics.