Performance Tuning
Compress responses, cache aggressively, keep the event loop free, and measure before you optimize.
Express itself is rarely your bottleneck — your handlers are. Still, a handful of framework-level habits give real wins, and one rule governs all of them: measure first. Profile, find the actual hot path, then optimize that.
High-leverage wins
- Compression — gzip/brotli responses. Best done at your reverse proxy (Nginx) so Node spends its cycles on your logic, not on compressing bytes.
- HTTP caching — set
Cache-ControlandETagon cacheable responses so clients and CDNs skip the round trip entirely. The cheapest request is the one you never receive. - Never block the event loop — one synchronous
JSON.parseof a huge payload, a sync crypto call, or a tight loop stalls every concurrent request. Offload CPU-heavy work to a worker thread. - Reuse connections — one shared DB pool, one keep-alive HTTP agent for outbound calls.
Set NODE_ENV=production
Express caches view lookups and skips verbose error work when NODE_ENV=production. Forgetting this flag alone can cost a measurable chunk of throughput.
Example
import express from 'express';
import { Worker } from 'node:worker_threads';
const app = express();
// 1. HTTP caching: let clients and CDNs skip the round trip.
app.get('/api/catalog', (req, res) => {
const catalog = getCatalog();
res.set('Cache-Control', 'public, max-age=300'); // 5 min
res.json(catalog); // res.json sets a weak ETag automatically
});
// 2. Keep the event loop free: offload CPU-bound work to a worker thread.
app.get('/report/:id', (req, res, next) => {
const worker = new Worker('./workers/report.js', {
workerData: { id: req.params.id },
});
worker.once('message', (data) => res.json(data));
worker.once('error', next); // forwarded to the error handler
});
// 3. Compression is usually better at the proxy, but if you must do it in
// Node, do it conditionally — never compress already-compressed assets.
// import compression from 'compression';
// app.use(compression({ threshold: 1024 }));
// Remember: NODE_ENV=production unlocks Express's own fast paths.When to use it
- A developer adds compression middleware to an Express API so JSON responses are gzip-compressed, reducing payload sizes by up to 70%.
- A team caches expensive database query results in Redis so repeated GET requests return in under 1 ms instead of querying the database every time.
- A developer profiles an Express route with clinic.js to find a slow middleware and replaces it with a lightweight alternative.
More examples
Response compression
Registers compression with a size threshold and an override header so callers can opt out of compression for streaming endpoints.
const compression = require('compression');
const express = require('express');
const app = express();
app.use(compression({
level: 6, // zlib level 1-9
threshold: 1024, // only compress responses > 1 KB
filter: (req, res) => {
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
},
}));
app.get('/data', (req, res) => res.json(largeArray));
app.listen(3000);Redis response cache middleware
A cache middleware checks Redis for a stored response; on a hit it returns immediately, on a miss it intercepts res.json to store the result.
const redis = require('redis');
const client = redis.createClient({ url: process.env.REDIS_URL });
client.connect();
function cache(ttlSeconds) {
return async (req, res, next) => {
const key = `cache:${req.url}`;
const hit = await client.get(key);
if (hit) return res.json(JSON.parse(hit));
res.sendJson = res.json.bind(res);
res.json = (body) => {
client.setEx(key, ttlSeconds, JSON.stringify(body));
res.sendJson(body);
};
next();
};
}
app.get('/products', cache(60), ctrl.listProducts);Keep-Alive and connection pooling
Tunes keepAliveTimeout above the AWS ALB idle timeout to prevent the load balancer from closing connections that Express still considers open.
const http = require('http');
const app = require('./app');
const server = http.createServer(app);
server.keepAliveTimeout = 65_000; // longer than ALB idle timeout
server.headersTimeout = 66_000;
server.listen(process.env.PORT || 3000, () => {
console.log('Server with keep-alive tuning started');
});
Discussion