Performance and Compression
A functional Express app is not automatically a fast one. Response size, unnecessary computations, missing caches, and uncompressed data all slow down your application and increase hosting costs. This topic covers the techniques professional teams use to measure and improve Express application performance: compression, caching, response time headers, clustering, and connection pooling.
Why Performance Matters
┌──────────────────────────────────────────────────────────────────┐ │ Impact of Slow Response Times │ ├──────────────────────────────────────────────────────────────────┤ │ 100ms delay → Users barely notice │ │ 1 second → Users notice, engagement drops 7% │ │ 3 seconds → 40% of mobile users abandon the page │ │ 5 seconds → Bounce rate doubles │ └──────────────────────────────────────────────────────────────────┘
Layer 1: Compress Responses with gzip
Text responses (HTML, JSON, CSS) compress well. A 100KB JSON response can shrink to 15KB with gzip compression, reducing transfer time by 85%. The compression middleware adds this automatically:
npm install compression
const compression = require('compression');
// Add compression before routes — compresses all responses
app.use(compression({
level: 6, // Compression level: 1 (fastest) to 9 (smallest)
threshold: 1024 // Only compress responses larger than 1KB
}));
app.get('/api/large-dataset', (req, res) => {
const bigData = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: 'A detailed description of this item'
}));
res.json(bigData);
// Without compression: ~800KB
// With gzip compression: ~50KB — 94% smaller
});
Compression Savings by Content Type
┌──────────────────────────────────────────────────────────────┐ │ Typical Compression Ratios │ ├───────────────────┬──────────────────────────────────────────┤ │ Content Type │ Typical Compression Ratio │ ├───────────────────┼──────────────────────────────────────────┤ │ JSON │ 60–90% smaller │ │ HTML │ 70–80% smaller │ │ CSS │ 70–80% smaller │ │ JavaScript │ 60–70% smaller │ │ JPEG/PNG images │ 0–5% (already compressed) │ │ Gzipped files │ 0% (cannot compress twice) │ └───────────────────┴──────────────────────────────────────────┘
Layer 2: HTTP Caching Headers
Caching tells browsers and CDNs to reuse a previous response instead of requesting it again. Static assets rarely change — you can cache them for days or months.
// Cache static assets for 30 days
app.use(express.static('public', {
maxAge: '30d',
etag: true, // Fingerprint for cache validation
lastModified: true
}));
// Cache an API response for 5 minutes
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=300'); // 300 seconds = 5 minutes
res.json({ data: products });
});
// Never cache user-specific or sensitive data
app.get('/api/profile', protect, (req, res) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.json({ user: req.user });
});
Layer 3: Response Time Tracking
The response-time middleware adds an X-Response-Time header to every response, showing how long your server took to generate it. This helps identify slow endpoints:
npm install response-time
const responseTime = require('response-time');
app.use(responseTime()); // Adds X-Response-Time: 45.123ms to headers
Build a custom version that logs slow requests to your console:
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
if (duration > 500) {
console.warn(`SLOW REQUEST: ${req.method} ${req.path} — ${duration}ms`);
}
});
next();
});
Layer 4: In-Memory Caching with node-cache
Database queries are often the slowest part of a request. Cache frequently read, rarely changing data in memory so most requests never hit the database:
npm install node-cache
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300 }); // Default TTL: 5 minutes
app.get('/api/categories', async (req, res) => {
const cacheKey = 'categories';
// Check cache first
const cached = cache.get(cacheKey);
if (cached) {
return res.json({ source: 'cache', data: cached });
}
// Cache miss — query database
const categories = await Category.find();
// Save to cache for 5 minutes
cache.set(cacheKey, categories);
res.json({ source: 'database', data: categories });
});
Cache Flow Diagram
Request: GET /api/categories
│
▼
Check memory cache
│ │
HIT ─┘ └─ MISS
│ │
▼ ▼
Return cached Query database
data instantly (slow: 50–500ms)
(fast: <1ms) │
▼
Store in cache
│
▼
Return data
Layer 5: Node.js Clustering
Node.js runs in a single thread by default. A server with 8 CPU cores uses only one of them. Clustering creates one worker process per CPU core, multiplying your throughput:
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
const numCPUs = os.cpus().length;
console.log(`Master process starting ${numCPUs} workers`);
// Fork one worker per CPU core
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// Restart crashed workers
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.id} died — restarting`);
cluster.fork();
});
} else {
// Each worker runs the full Express app
const app = require('./app');
app.listen(3000, () => {
console.log(`Worker ${process.pid} started`);
});
}
Without clustering: With clustering (4 cores): 1 CPU core 4 CPU cores 1 request at a time 4 requests simultaneously 1000 req/sec max ~4000 req/sec capacity
PM2 (covered in Topic 20) automates clustering with a single flag:
pm2 start app.js -i max # Starts one process per CPU automatically
Layer 6: Avoid Blocking the Event Loop
Node.js handles all requests on a single thread using an event loop. Any synchronous operation that takes long — like a heavy computation or a synchronous file read — blocks every other request while it runs:
// BAD — blocks the event loop for all requests
app.get('/compute', (req, res) => {
let result = 0;
for (let i = 0; i < 1_000_000_000; i++) {
result += i; // Runs for ~2 seconds — all other requests wait!
}
res.json({ result });
});
// BETTER — offload heavy work to a worker thread
const { Worker } = require('worker_threads');
app.get('/compute', (req, res) => {
const worker = new Worker('./heavyTask.js');
worker.on('message', (result) => {
res.json({ result });
});
// Event loop stays free to handle other requests
});
Performance Checklist
┌──────────────────────────────────────────────────────────────────┐ │ Performance Optimization Checklist │ ├───────────────────────────────────────────────┬──────────────────┤ │ gzip compression enabled │ ✓ or ✗ │ │ Static assets cached with maxAge │ ✓ or ✗ │ │ Frequently read data cached in memory │ ✓ or ✗ │ │ Database queries use indexes │ ✓ or ✗ │ │ Only needed fields selected from DB │ ✓ or ✗ │ │ No synchronous I/O in request handlers │ ✓ or ✗ │ │ Clustering enabled in production │ ✓ or ✗ │ │ Response time monitoring in place │ ✓ or ✗ │ │ Slow requests logged and investigated │ ✓ or ✗ │ └───────────────────────────────────────────────┴──────────────────┘
Summary
Performance optimization in Express works through layers. Add the compression middleware to shrink response payloads by 60–90% with gzip. Set Cache-Control headers on static assets and stable API responses so browsers and CDNs avoid repeat requests. Use node-cache to store database results in memory and serve repeat requests in under a millisecond. Enable clustering with PM2's -i max flag to use all available CPU cores. Track slow requests by measuring response time in a middleware and logging anything over your threshold. Avoid synchronous blocking operations in route handlers — use async I/O and offload heavy computations to worker threads to keep the event loop responsive.
