Performance Optimization & Caching
Performance isn’t just about speed—it’s about efficiency, scalability, and cost. A well-optimized Node.js application can handle 10x more traffic on the same hardware.Performance Bottlenecks
| Bottleneck | Symptoms | Solution |
|---|---|---|
| CPU-bound | High CPU, slow responses | Clustering, worker threads |
| Memory-bound | High memory, OOM crashes | Memory optimization, streaming |
| I/O-bound | Waiting on DB, network | Caching, connection pooling |
| Event loop blocking | All requests slow down | Async operations, offload work |
Measuring Performance
Built-in Profiling
Copy
// Basic timing
console.time('operation');
// ... do something
console.timeEnd('operation'); // operation: 123ms
// Performance hooks
const { performance, PerformanceObserver } = require('perf_hooks');
const obs = new PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
console.log(`${entry.name}: ${entry.duration}ms`);
});
});
obs.observe({ entryTypes: ['measure'] });
performance.mark('start');
// ... operation
performance.mark('end');
performance.measure('My Operation', 'start', 'end');
Load Testing with autocannon
Copy
npm install -g autocannon
# Basic load test
autocannon http://localhost:3000/api/users
# Custom options
autocannon -c 100 -d 30 -p 10 http://localhost:3000/api/users
# -c: connections (concurrent)
# -d: duration in seconds
# -p: pipelining factor
Memory Profiling
Copy
// Memory usage
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)} MB`, // Total memory
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`, // V8 heap
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`, // Used heap
external: `${Math.round(used.external / 1024 / 1024)} MB` // C++ objects
});
// Expose for monitoring
app.get('/api/health', (req, res) => {
const memory = process.memoryUsage();
res.json({
uptime: process.uptime(),
memory: {
rss: memory.rss,
heapUsed: memory.heapUsed,
heapTotal: memory.heapTotal
}
});
});
Caching Strategies
In-Memory Cache with node-cache
Copy
const NodeCache = require('node-cache');
const cache = new NodeCache({
stdTTL: 300, // Default TTL: 5 minutes
checkperiod: 60, // Check for expired keys every 60s
maxKeys: 1000 // Limit cache size
});
// Cache middleware
const cacheMiddleware = (duration) => (req, res, next) => {
const key = `__express__${req.originalUrl}`;
const cached = cache.get(key);
if (cached) {
return res.json(cached);
}
// Override res.json to cache the response
const originalJson = res.json.bind(res);
res.json = (data) => {
cache.set(key, data, duration);
return originalJson(data);
};
next();
};
// Usage
app.get('/api/products', cacheMiddleware(300), async (req, res) => {
const products = await Product.find();
res.json(products);
});
// Manual cache operations
cache.set('user:123', userData, 3600); // 1 hour
const user = cache.get('user:123');
cache.del('user:123');
cache.flushAll();
Redis Cache
Copy
npm install redis
Copy
const { createClient } = require('redis');
const redis = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
redis.connect();
// Cache wrapper function
const cacheWrapper = async (key, ttl, fetchFn) => {
// Try to get from cache
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Fetch fresh data
const data = await fetchFn();
// Store in cache
await redis.setEx(key, ttl, JSON.stringify(data));
return data;
};
// Usage
app.get('/api/users/:id', async (req, res) => {
const user = await cacheWrapper(
`user:${req.params.id}`,
3600, // 1 hour
() => User.findById(req.params.id)
);
res.json(user);
});
// Cache invalidation
const invalidateUserCache = async (userId) => {
await redis.del(`user:${userId}`);
await redis.del('users:all');
};
// Pattern-based invalidation
const invalidatePattern = async (pattern) => {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(keys);
}
};
// Usage: invalidatePattern('users:*');
Cache-Aside Pattern
Copy
class CacheService {
constructor(redis, defaultTTL = 3600) {
this.redis = redis;
this.defaultTTL = defaultTTL;
}
async get(key) {
const data = await this.redis.get(key);
return data ? JSON.parse(data) : null;
}
async set(key, value, ttl = this.defaultTTL) {
await this.redis.setEx(key, ttl, JSON.stringify(value));
}
async getOrSet(key, fetchFn, ttl = this.defaultTTL) {
let data = await this.get(key);
if (data === null) {
data = await fetchFn();
if (data !== null && data !== undefined) {
await this.set(key, data, ttl);
}
}
return data;
}
async invalidate(key) {
await this.redis.del(key);
}
async invalidateMany(keys) {
if (keys.length > 0) {
await this.redis.del(keys);
}
}
}
// Repository with caching
class UserRepository {
constructor(cacheService) {
this.cache = cacheService;
}
async findById(id) {
return this.cache.getOrSet(
`user:${id}`,
() => User.findById(id).lean(),
3600
);
}
async findAll() {
return this.cache.getOrSet(
'users:all',
() => User.find().lean(),
300
);
}
async update(id, data) {
const user = await User.findByIdAndUpdate(id, data, { new: true });
await this.cache.invalidate(`user:${id}`);
await this.cache.invalidate('users:all');
return user;
}
}
HTTP Caching
Copy
// ETags for conditional requests
const etag = require('etag');
app.get('/api/data', async (req, res) => {
const data = await fetchData();
const dataEtag = etag(JSON.stringify(data));
// Check If-None-Match header
if (req.headers['if-none-match'] === dataEtag) {
return res.status(304).end(); // Not Modified
}
res.set('ETag', dataEtag);
res.set('Cache-Control', 'private, max-age=300');
res.json(data);
});
// Cache-Control headers
app.get('/api/static-data', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400'); // 24 hours
res.json(staticData);
});
// No cache for dynamic data
app.get('/api/user/profile', auth, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(req.user);
});
Clustering
Node.js is single-threaded, but clustering allows you to use all CPU cores.Copy
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
console.log(`Forking ${numCPUs} workers...`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// Handle worker death
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Restarting...`);
cluster.fork();
});
} else {
// Workers share the TCP connection
const app = require('./app');
app.listen(3000, () => {
console.log(`Worker ${process.pid} started`);
});
}
PM2 Cluster Mode (Recommended)
Copy
# Start in cluster mode
pm2 start app.js -i max # Use all CPUs
pm2 start app.js -i 4 # Use 4 instances
# Zero-downtime reload
pm2 reload app
# Monitoring
pm2 monit
Database Optimization
Connection Pooling
Copy
// PostgreSQL with pg
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Max connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Mongoose connection pooling
mongoose.connect(process.env.MONGO_URI, {
maxPoolSize: 10, // Max connections
minPoolSize: 2, // Min connections
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
});
Query Optimization
Copy
// ❌ N+1 query problem
const users = await User.find();
for (const user of users) {
const posts = await Post.find({ userId: user._id }); // N queries!
}
// ✅ Use population
const users = await User.find().populate('posts');
// ✅ Or use aggregation
const usersWithPosts = await User.aggregate([
{
$lookup: {
from: 'posts',
localField: '_id',
foreignField: 'userId',
as: 'posts'
}
}
]);
// ✅ Pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const users = await User.find()
.skip((page - 1) * limit)
.limit(limit)
.lean(); // Return plain objects (faster)
// ✅ Select only needed fields
const users = await User.find()
.select('name email')
.lean();
// ✅ Use indexes
// In schema definition
UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ createdAt: -1 });
UserSchema.index({ firstName: 1, lastName: 1 }); // Compound index
Response Compression
Copy
const compression = require('compression');
app.use(compression({
level: 6, // Compression level (1-9)
threshold: 1024, // Min size to compress (bytes)
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
}
}));
Async Optimization
Copy
// ❌ Sequential (slow)
const user = await getUser(id);
const posts = await getPosts(id);
const comments = await getComments(id);
// ✅ Parallel (fast)
const [user, posts, comments] = await Promise.all([
getUser(id),
getPosts(id),
getComments(id)
]);
// ✅ With error handling
const results = await Promise.allSettled([
getUser(id),
getPosts(id),
getComments(id)
]);
const data = results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
console.error(`Request ${index} failed:`, result.reason);
return null;
});
Memory Management
Copy
// ❌ Memory leak - unbounded array
const cache = [];
app.get('/data', (req, res) => {
const data = fetchLargeData();
cache.push(data); // Never cleared!
res.json(data);
});
// ✅ Use LRU cache with size limit
const LRU = require('lru-cache');
const cache = new LRU({
max: 100, // Max items
maxSize: 50 * 1024 * 1024, // 50MB
sizeCalculation: (value) => JSON.stringify(value).length,
ttl: 1000 * 60 * 5 // 5 minutes
});
// ✅ Stream large data instead of loading into memory
app.get('/download', (req, res) => {
const stream = fs.createReadStream('large-file.csv');
stream.pipe(res);
});
// ✅ Process large datasets in chunks
async function processLargeDataset() {
const cursor = Model.find().cursor();
for await (const doc of cursor) {
await processDocument(doc);
}
}
Summary
- Measure first - Use profiling tools before optimizing
- Cache aggressively - Redis for distributed, in-memory for local
- Use connection pooling - Don’t create new connections per request
- Optimize queries - Avoid N+1, use indexes, select only needed fields
- Enable compression - Reduce response sizes
- Run parallel operations - Use Promise.all when possible
- Cluster for CPU scaling - Use PM2 in cluster mode
- Stream large data - Don’t load everything into memory
- Monitor continuously - Track performance metrics in production