Node.js Performance Optimization: A Practical Guide
Node.js Performance Optimization: A Practical Guide
Building fast Node.js applications requires understanding how the runtime works and applying optimization techniques at every layer. This guide covers practical strategies to make your Node.js applications faster and more efficient.
Understanding Node.js Performance Characteristics
Node.js has unique performance characteristics due to its single-threaded, event-driven architecture:
- Single Event Loop: All JavaScript code runs on a single thread
- Non-blocking I/O: I/O operations don't block the event loop
- V8 Engine: JavaScript is compiled to machine code
- Libuv: Handles async I/O in a thread pool
1. Async Patterns and Best Practices
Avoid Blocking the Event Loop
// Bad: Synchronous operations block the event loop
const data = fs.readFileSync('large-file.json', 'utf8');
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512');
// Good: Use async versions
fs.readFile('large-file.json', 'utf8', (err, data) => {
// Handle data
});
crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, hash) => {
// Handle hash
});
// Better: Use promises with async/await
const data = await fs.promises.readFile('large-file.json', 'utf8');
const hash = await new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, hash) => {
if (err) reject(err);
else resolve(hash);
});
});
Use setImmediate for CPU-Intensive Tasks
// Bad: Blocking the event loop with CPU work
function processLargeArray(array) {
const results = [];
for (const item of array) {
results.push(heavyComputation(item));
}
return results;
}
// Good: Break up work with setImmediate
function processLargeArrayAsync(array, callback) {
const results = [];
let index = 0;
function processChunk() {
const chunk = array.slice(index, index + 100);
index += 100;
for (const item of chunk) {
results.push(heavyComputation(item));
}
if (index < array.length) {
setImmediate(processChunk);
} else {
callback(results);
}
}
processChunk();
}
Worker Threads for CPU-Intensive Work
// main.js
const { Worker } = require('worker_threads');
function runHeavyComputation(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', {
workerData: data
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}
// worker.js
const { parentPort, workerData } = require('worker_threads');
const result = heavyComputation(workerData);
parentPort.postMessage(result);
2. Memory Management
Understand V8 Memory Limits
// Check memory usage
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)} MB`,
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`,
external: `${Math.round(used.external / 1024 / 1024)} MB`
});
// Increase memory limit (use carefully)
// node --max-old-space-size=4096 app.js
Avoid Memory Leaks
// Bad: Closure capturing large object
function createHandler() {
const largeData = new Array(1000000).fill('data');
return function handler(req, res) {
// largeData is retained in memory even if not used
res.send('OK');
};
}
// Good: Don't capture unnecessary references
function createHandler() {
return function handler(req, res) {
res.send('OK');
};
}
// Bad: Global accumulation
const cache = {};
app.get('/data/:id', (req, res) => {
cache[req.params.id] = largeObject; // Cache grows indefinitely
res.json(cache[req.params.id]);
});
// Good: Use LRU cache
const LRU = require('lru-cache');
const cache = new LRU({
max: 500, // Maximum items
maxAge: 1000 * 60 * 5 // 5 minutes TTL
});
app.get('/data/:id', (req, res) => {
cache.set(req.params.id, largeObject);
res.json(cache.get(req.params.id));
});
Stream Large Data
// Bad: Load entire file into memory
app.get('/download', async (req, res) => {
const data = await fs.promises.readFile('huge-file.zip');
res.send(data);
});
// Good: Stream the file
app.get('/download', (req, res) => {
const stream = fs.createReadStream('huge-file.zip');
stream.pipe(res);
});
// Good: Stream processing
const { pipeline } = require('stream/promises');
const { createGzip } = require('zlib');
async function compressFile(inputPath, outputPath) {
await pipeline(
fs.createReadStream(inputPath),
createGzip(),
fs.createWriteStream(outputPath)
);
}
3. Clustering for Multi-Core Utilization
Using the Cluster Module
const cluster = require('cluster');
const os = require('os');
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`Primary ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died`);
cluster.fork(); // Restart worker
});
} else {
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send(`Worker ${process.pid} responded`);
});
app.listen(3000);
console.log(`Worker ${process.pid} started`);
}
Using PM2 for Process Management
// ecosystem.config.js
module.exports = {
apps: [{
name: 'my-app',
script: './app.js',
instances: 'max', // Use all CPU cores
exec_mode: 'cluster', // Cluster mode
max_memory_restart: '1G', // Restart on memory limit
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
};
// Run: pm2 start ecosystem.config.js
4. Database Optimization
Connection Pooling
// Bad: New connection per request
app.get('/users', async (req, res) => {
const connection = await mysql.createConnection(config);
const users = await connection.query('SELECT * FROM users');
await connection.end();
res.json(users);
});
// Good: Use connection pool
const pool = mysql.createPool({
connectionLimit: 10,
host: 'localhost',
user: 'root',
database: 'mydb'
});
app.get('/users', async (req, res) => {
const users = await pool.query('SELECT * FROM users');
res.json(users);
});
Query Optimization
// Bad: N+1 query problem
app.get('/posts', async (req, res) => {
const posts = await db.query('SELECT * FROM posts');
for (const post of posts) {
post.comments = await db.query(
'SELECT * FROM comments WHERE post_id = ?',
[post.id]
);
}
res.json(posts);
});
// Good: Use JOIN or batch query
app.get('/posts', async (req, res) => {
const posts = await db.query(`
SELECT posts.*, comments.*
FROM posts
LEFT JOIN comments ON posts.id = comments.post_id
`);
// Or batch load comments
const postIds = posts.map(p => p.id);
const comments = await db.query(
'SELECT * FROM comments WHERE post_id IN (?)',
[postIds]
);
// Map comments to posts
const commentsByPostId = _.groupBy(comments, 'post_id');
posts.forEach(post => {
post.comments = commentsByPostId[post.id] || [];
});
res.json(posts);
});
Caching Strategies
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300 }); // 5 minutes
async function getUsers() {
const cacheKey = 'users:all';
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const users = await db.query('SELECT * FROM users');
cache.set(cacheKey, users);
return users;
}
// Redis for distributed caching
const Redis = require('ioredis');
const redis = new Redis();
async function getCachedData(key, fetchFn, ttl = 300) {
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const data = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
5. HTTP and API Optimization
Compression
const express = require('express');
const compression = require('compression');
const app = express();
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
threshold: 1024, // Only compress responses > 1KB
level: 6 // Compression level (1-9)
}));
Response Caching Headers
// Static assets
app.use(express.static('public', {
maxAge: '1y', // Cache for 1 year
immutable: true // Never revalidate
}));
// API responses
app.get('/api/data', (req, res) => {
res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
res.json(data);
});
// ETags for conditional requests
app.get('/api/users', async (req, res) => {
const users = await getUsers();
res.set('ETag', generateETag(users));
if (req.headers['if-none-match'] === res.get('ETag')) {
return res.status(304).end(); // Not Modified
}
res.json(users);
});
Rate Limiting
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per windowMs
message: 'Too many requests, please try again later'
});
app.use('/api/', limiter);
// Per-user rate limiting
const userLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
keyGenerator: (req) => req.user?.id || req.ip
});
app.use('/api/sensitive', userLimiter);
6. Profiling and Monitoring
Built-in Profiler
# Run with profiler
node --prof app.js
# Process isolate file
node --prof-process isolate-*.log > profile.txt
Using the Inspector
// Enable inspector
// node --inspect app.js
// In Chrome DevTools: chrome://inspect
// Programmatic profiling
const inspector = require('inspector');
const fs = require('fs');
const session = new inspector.Session();
session.connect();
session.post('Profiler.enable', () => {
session.post('Profiler.start', () => {
// Run some code
heavyOperation();
session.post('Profiler.stop', (err, { profile }) => {
fs.writeFileSync('./profile.cpuprofile', JSON.stringify(profile));
});
});
});
Application Performance Monitoring (APM)
// Using New Relic, Datadog, or similar
require('newrelic');
// Or use OpenTelemetry
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
const provider = new NodeTracerProvider();
provider.addSpanProcessor(
new BatchSpanProcessor(new JaegerExporter())
);
provider.register();
Custom Metrics
const promClient = require('prom-client');
// Enable default metrics
promClient.collectDefaultMetrics();
// Custom metrics
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status']
});
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on('finish', () => {
end({ method: req.method, route: req.path, status: res.statusCode });
});
next();
});
// Expose metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.send(await promClient.register.metrics());
});
7. Best Practices Checklist
- Always use async versions of I/O operations
- Use streams for large data processing
- Implement proper caching at multiple levels
- Use connection pooling for databases
- Run in cluster mode to utilize all CPU cores
- Enable compression for HTTP responses
- Set appropriate caching headers
- Implement rate limiting to prevent abuse
- Monitor performance continuously
- Profile regularly to identify bottlenecks
Conclusion
Node.js performance optimization is a multi-layered effort. By understanding the event loop, managing memory efficiently, utilizing all CPU cores, and implementing proper caching and monitoring, you can build high-performance applications that scale.
Remember: Premature optimization is the root of all evil. Always measure first, then optimize the actual bottlenecks.