Every performance tutorial tells you to "just use Redis" but nobody explains that Redis will eat your RAM and your cache invalidation strategy will make you cry. After enough 3am pages because the site went down, here's what actually breaks Express apps in production.
The Performance Bottlenecks That Actually Kill Your App
Before you start optimizing, understand what's actually slow. NodeSource's 2024 performance research shows Node.js v22 made buffers way faster - like 200%+ faster. WebStreams got a huge boost too, so fetch
finally doesn't completely suck. Went from 2,246 to 2,689 requests/second, which is still not great but at least it's not embarrassing anymore.
Source: NodeSource State of Node.js Performance 2024 But your Express app is probably still slow because of these real bottlenecks:
Database Queries Are Your #1 Enemy
Your database queries are almost certainly the performance killer, not Express itself. The official Express docs mention this but they're useless as always. Here's what actually breaks:
// This route looked harmless until Black Friday when it brought down our entire app.
// The N+1 queries went crazy - I think we hit like 50,000 database connections or something insane.
// Our AWS bill was absolutely brutal that month.
app.get('/users', async (req, res) => {
const users = await User.findAll({
include: [Profile, Posts, Comments] // Death by a thousand cuts
});
res.json(users); // Massive JSON response that killed everything
});
// After the outage, we fixed it like this. Took forever to
// find all the other places doing the same shit.
app.get('/users', async (req, res) => {
const users = await User.findAll({
attributes: ['id', 'name', 'email'],
limit: 50, // Because pagination is not optional
include: [{
model: Profile,
attributes: ['avatar'] // Stop fetching 47 fields you don't use
}]
});
res.json(users);
});
Use connection pooling or your database will become the bottleneck. For PostgreSQL with node-postgres, configure a proper pool:
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,
port: 5432,
max: 20, // Maximum pool size
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return error after 2 seconds if connection could not be established
});
Synchronous Operations Will Tank Everything
One synchronous operation in your middleware stack will block the entire event loop. These are the usual suspects that kill performance:
// These will destroy your throughput
const data = fs.readFileSync('/large-file.json'); // Blocks everything
const result = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512'); // CPU intensive
const parsed = JSON.parse(massiveJsonString); // Blocks on large payloads
// Use async versions
const data = await fs.promises.readFile('/large-file.json');
const result = await new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
The Node.js performance measurement APIs help you find these bottlenecks. Clinic.js and 0x profiler are also solid for production profiling. I spent 3 days tracking down a "random" slowdown that turned out to be someone importing a 50MB CSV file synchronously in middleware:
const { performance, PerformanceObserver } = require('perf_hooks');
const obs = new PerformanceObserver((items) => {
items.getEntries().forEach((entry) => {
if (entry.duration > 100) { // Log operations taking >100ms
console.warn(`Slow operation: ${entry.name} took ${entry.duration}ms`);
}
});
});
obs.observe({ entryTypes: ['measure'] });
// Measure your operations
performance.mark('db-query-start');
const users = await User.findAll();
performance.mark('db-query-end');
performance.measure('db-query', 'db-query-start', 'db-query-end');
Memory Leaks From Event Listeners and Closures
Memory leaks are the gift that keeps on giving. Spent 2 weeks chasing a leak earlier this year that would slowly kill our app - memory usage climbed from like 200MB to 8GB over 12 hours, then OOM crash. The leak? Socket.IO event listeners that never got cleaned up when users disconnected. We had something insane like 50k uncleaned event listeners after a busy day.
// This killed our app slowly
io.on('connection', (socket) => {
socket.on('message', handleMessage); // Never cleaned up
socket.on('disconnect', () => {
console.log('User disconnected');
// Missing: socket.removeAllListeners();
});
});
// Fixed version that doesn't leak
io.on('connection', (socket) => {
const messageHandler = (data) => handleMessage(socket, data);
socket.on('message', messageHandler);
socket.on('disconnect', () => {
console.log('User disconnected');
socket.removeAllListeners();
socket = null; // Help GC
});
});
Profile memory with Node.js built-ins: node --inspect app.js
then Chrome DevTools, or clinic.js for automated analysis: npx clinic doctor -- node app.js
. Memray is also excellent for memory profiling.
// This shit will eat your memory slowly and kill your app at 3am
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
// Every request adds another listener that never dies
user.on('update', (data) => {
// Closure holds onto req/res forever - memory leak city
console.log(`User ${req.params.id} updated:`, data);
});
res.json(user);
});
// Fixed after the 4th production crash. Now we actually clean up.
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
const userId = req.params.id; // Don't hold the whole request
const updateHandler = (data) => {
console.log(`User ${userId} updated:`, data);
};
user.once('update', updateHandler); // Dies after one use
res.json(user);
});
Use Node.js built-in profiler or modern alternatives to profile memory usage and find leaks:
## Use Node.js built-in profiler
node --prof app.js
## Generate profile report
node --prof-process isolate-*.log > profile.txt
## Or use modern alternatives like 0x
npm install -g 0x
0x app.js
## For heap snapshots
node --inspect app.js
## Then use Chrome DevTools -> chrome://inspect
Redis Caching (And Why It'll Break Your Shit)
Redis is magic until it randomly stops working and your cache becomes a black hole of failed lookups. Spent a fun weekend learning that Redis memory eviction policies are not suggestions - they will delete your session data without asking.
Anyway, here's how to cache without destroying your sanity:
Application-Level Caching with TTL
const Redis = require('redis');
const client = Redis.createClient({
host: process.env.REDIS_HOST,
retryDelayOnFailover: 100,
maxRetriesPerRequest: 3
});
const cache = {
async get(key) {
try {
const value = await client.get(key);
return value ? JSON.parse(value) : null;
} catch (error) {
console.error('Redis shit the bed again:', error);
// Return null and carry on - Redis is down more than you think
return null;
}
},
async set(key, value, ttlSeconds = 300) {
try {
await client.setex(key, ttlSeconds, JSON.stringify(value));
} catch (error) {
console.error('Redis write failed (again):', error);
// Don't throw - your app shouldn't die because cache is broken
// We learned this the hard way when Redis ran out of memory at 2am
}
}
};
app.get('/expensive-data', async (req, res) => {
const cacheKey = `expensive-data:${req.query.filter}`;
// Try cache first
let data = await cache.get(cacheKey);
if (!data) {
// Cache miss - get from database
data = await ExpensiveQuery.run(req.query.filter);
await cache.set(cacheKey, data, 600); // 10 minute TTL
}
res.json(data);
});
HTTP Caching Headers
Set proper caching headers to reduce server load:
// For static API responses
app.get('/api/config', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=3600', // 1 hour
'ETag': generateETag(configData)
});
res.json(configData);
});
// For user-specific data
app.get('/api/user/profile', authenticate, (req, res) => {
res.set({
'Cache-Control': 'private, max-age=300', // 5 minutes, private to user
'Vary': 'Authorization' // Cache varies by auth header
});
res.json(req.user.profile);
});
Compression and Response Optimization
Enable compression but configure it properly. The default settings are usually wrong for production:
const compression = require('compression');
app.use(compression({
filter: (req, res) => {
// Don't compress if client doesn't accept encoding
if (req.headers['x-no-compression']) return false;
// Don't compress tiny responses
const contentLength = res.get('Content-Length');
if (contentLength && parseInt(contentLength) < 1024) return false;
return compression.filter(req, res);
},
level: 6, // Balance between speed and compression ratio
threshold: 1024, // Only compress responses > 1KB
windowBits: 15, // Maximum window size for better compression
memLevel: 8 // Memory usage vs speed tradeoff
}));
NodeSource's 2024 numbers show gzip compression can reduce payload sizes by 70% with minimal CPU overhead when properly configured.
Request Parsing and Body Size Limits
Here's how to make Express not die under real traffic:
// Prevent DoS attacks from large payloads
app.use(express.json({
limit: '10mb', // Adjust based on your needs
verify: (req, res, buf, encoding) => {
// Basic JSON validation
try {
JSON.parse(buf);
} catch (e) {
throw new Error('Invalid JSON payload');
}
}
}));
app.use(express.urlencoded({
limit: '10mb',
extended: true,
parameterLimit: 1000 // Prevent parameter pollution
}));
// Add request timeout
app.use((req, res, next) => {
req.setTimeout(30000, () => {
res.status(408).json({ error: 'Request timeout' });
});
next();
});
Static File Serving Optimization
Don't serve static files through Express in production unless you have to:
// Development only
if (process.env.NODE_ENV === 'development') {
app.use('/static', express.static('public', {
maxAge: '1h',
etag: true
}));
} else {
// Production: serve static files through reverse proxy (nginx)
// or CDN, not through Express
}
Express 5.0 and Node.js 22 - The Real Production Story
Upgraded to Express 5.0 and Node.js 22 in production? Congrats on being brave (or stupid). Here's what actually breaks and what actually helps:
Express 5.0 Async Handling - Finally Works, Mostly
Express 5.0 finally catches async errors automatically. No more asyncHandler
wrapper bullshit:
// Express 4 - this would crash your app silently
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id); // Unhandled rejection = dead app
res.json(user);
});
// Express 5 - this actually works now
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id); // Auto-caught if it throws
res.json(user);
});
BUT watch out for this gotcha - I spent 2 days fixing middleware that relied on the old error bubbling behavior:
// This broke in Express 5 migration
app.use((req, res, next) => {
req.startTime = Date.now();
next();
});
app.get('/test', async (req, res, next) => {
await someAsyncOperation();
// In Express 4, errors here would bubble up weirdly
// Express 5 handles them properly but some middleware expects the old flow
const duration = Date.now() - req.startTime;
res.json({ duration });
});
Node.js 22 Performance - The Good and The Bullshit
Node.js v22 delivered real performance gains that actually matter:
- Buffer operations: 200%+ faster - Finally doesn't suck for binary data
- WebStreams: 100%+ improvement - Makes
fetch
actually usable (went from 2,246 to 2,689 req/sec) - URL parsing: significantly faster - Good news if you do lots of routing
But watch out for these regressions that bit us:
- TextDecoder with Latin-1: nearly 100% slower - If you're handling weird encodings, benchmark first or you'll wonder why everything suddenly sucks
- zlib.deflate(): slower async performance - Compression took a hit, which is extra fun when you're already CPU-bound
Real production impact from our migration (e-commerce app, around 50k req/min peak):
Response times got noticeably better - went from like 145ms average to around 118ms. Memory usage improved too, dropped from 320MB to something like 280MB steady state. CPU usage under load went from 65% to maybe 58%. Not revolutionary but definitely worth it.
The Weird Shit That Breaks
Things that randomly broke during our Express 5/Node 22 migration:
- Custom error middleware stopped firing - Express 5 changed error propagation flow
- Some npm packages assume Express 4 behavior - Check your deps carefully
- Docker base image issues - Node 22 needs different Alpine/Debian versions and will randomly fail with "GLIBC not found" until you figure out the right combo
- Memory usage patterns changed - V8 GC behavior is different, tune your monitoring or get surprised by OOM kills at 3am
Migration Reality Check
Don't upgrade Express 5 and Node 22 at the same time in production. I learned this the hard way. Do Node first, test for 2 weeks, THEN do Express. Each change affects performance and stability differently.
## Safe migration path
1. Test Node 22 upgrade in staging for 2+ weeks
2. Monitor memory patterns, response times, error rates
3. Then upgrade Express to 5.x in a separate deploy
4. Watch for broken middleware and error handling
If you must serve static files through Express, optimize the configuration:
app.use('/static', express.static('public', {
maxAge: '1y', // Long cache for static assets
etag: true,
lastModified: true,
setHeaders: (res, path) => {
// Set appropriate headers based on file type
if (path.endsWith('.js') || path.endsWith('.css')) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
if (path.endsWith('.html')) {
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
}
}
}));
Want more ways to make Express not suck? The official performance best practices and Node.js performance guide have some decent stuff buried in there.