I've seen too many Node.js backends collapse under load because engineers optimized the wrong things. They micro-optimized array methods while ignoring blocking I/O. They added caching layers that increased complexity without reducing database load. After years of running Node.js services handling millions of requests daily, I've learned that performance in production comes down to a handful of patterns that most teams ignore.
The Event Loop Is Your Bottleneck (And You're Probably Blocking It)
Node.js is single-threaded. Every millisecond you spend doing synchronous work is a millisecond your server can't process other requests. The most common performance killer I see? Synchronous JSON parsing of large payloads. When you call JSON.parse() on a 5MB webhook payload, you're blocking the event loop for 50-100ms. At 1000 req/s, that's a disaster.
// Bad: Blocking the event loop
app.post('/webhook', express.json({ limit: '10mb' }), (req, res) => {
// express.json() already parsed synchronously - damage done
processWebhook(req.body);
res.sendStatus(200);
});
// Better: Stream and parse incrementally
const JSONStream = require('JSONStream');
app.post('/webhook', (req, res) => {
const parser = JSONStream.parse('*');
req.pipe(parser);
parser.on('data', (chunk) => {
// Process incrementally, yielding to event loop
setImmediate(() => processWebhookChunk(chunk));
});
parser.on('end', () => res.sendStatus(200));
parser.on('error', (err) => res.status(400).send(err.message));
});
setImmediate().Connection Pooling: The Optimization Everyone Forgets
Database connection pools are configured once and forgotten. Then production traffic spikes and suddenly your app is spending 80% of its time waiting for available connections. Default pool sizes are almost always wrong. PostgreSQL's default is 10 connections. MongoDB's is 5. For a service handling 100 concurrent requests, that's a 10:1 queue.
// Naive approach - connection per request
const { Pool } = require('pg');
const pool = new Pool({
max: 10, // Default - way too small
idleTimeoutMillis: 30000
});
// Production-ready approach
const pool = new Pool({
max: process.env.DB_POOL_SIZE || 50,
min: 10, // Keep warm connections
idleTimeoutMillis: 60000,
connectionTimeoutMillis: 5000,
// Critical: limit queue size to fail fast
maxUses: 7500, // Recycle connections
});
// Monitor pool health
setInterval(() => {
console.log({
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount
});
}, 10000);
I size pools based on this formula: (num_cpu_cores * 2) + effective_spindle_count. For cloud databases, start with 50 connections per instance and monitor. If waitingCount is consistently above 0, increase the pool. If queries are slow but idleCount is high, your bottleneck is elsewhere.
The Repository Pattern Prevents Performance Disasters
Raw SQL scattered across route handlers makes performance optimization impossible. You can't identify N+1 queries. You can't add caching layers. You can't switch databases. The repository pattern isn't just about clean architecture—it's about having a single place to optimize data access.
// repositories/UserRepository.js
class UserRepository {
constructor(pool, cache) {
this.pool = pool;
this.cache = cache;
}
async findById(id) {
const cacheKey = `user:${id}`;
const cached = await this.cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const result = await this.pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
if (result.rows[0]) {
await this.cache.set(cacheKey, JSON.stringify(result.rows[0]), 'EX', 300);
}
return result.rows[0];
}
async findByIds(ids) {
// Single query instead of N queries
const result = await this.pool.query(
'SELECT * FROM users WHERE id = ANY($1)',
[ids]
);
return result.rows;
}
}
module.exports = UserRepository;
Now when you discover an N+1 query in production, you fix it in one place. When you need to add DataLoader for GraphQL, you modify the repository. When Redis becomes a bottleneck, you swap in a local LRU cache. This pattern has saved me countless times during incidents.
Async Middleware Chains Will Burn You
Express middleware seems elegant until you have 12 middleware functions running on every request. Each one adds latency. Worse, if one middleware does blocking work, it affects every request. I've seen authentication middleware that validates JWTs synchronously add 50ms to every single request.
- Minimize middleware count: Combine related middleware (auth + rate limiting) into single functions
- Make middleware conditional: Only run body parsing on routes that need it, not globally
- Cache aggressively: JWT validation results can be cached in-memory for 30-60 seconds
- Profile middleware: Add timing logs to identify which middleware is slow
Worker Threads for CPU-Bound Tasks
If you're doing image processing, PDF generation, or complex data transformations in your request handlers, you're doing it wrong. These tasks should run in worker threads or separate processes. I use worker_threads for short CPU-intensive tasks and Bull queues for longer jobs. The pattern is simple: accept the request, queue the work, return immediately.
const { Worker } = require('worker_threads');
const Queue = require('bull');
const imageQueue = new Queue('image-processing', process.env.REDIS_URL);
app.post('/images/process', async (req, res) => {
const job = await imageQueue.add({
imageUrl: req.body.imageUrl,
operations: req.body.operations
});
res.json({ jobId: job.id, status: 'processing' });
});
// worker.js - separate process
imageQueue.process(async (job) => {
const { imageUrl, operations } = job.data;
// CPU-intensive work happens here, not in main process
const result = await processImage(imageUrl, operations);
return result;
});
This pattern transformed our API response times from 2-3 seconds to under 100ms. Users get instant feedback, and heavy processing happens asynchronously. If your API endpoints take more than 500ms, you're probably doing work that should be queued. The only exception is when users explicitly need to wait for results—and even then, consider WebSockets for progress updates.