Most Express performance problems are fixed by caching and not doing work, rather than by micro-optimisation.
Express itself is rarely the bottleneck; what your handlers do is. The big wins, roughly in order:
Run in production mode. NODE_ENV=production makes Express cache view templates and send less verbose errors, and many libraries take faster paths.
Don't block the event loop. Synchronous fs, crypto or giant JSON.stringify calls in a handler stall every concurrent request. Use async APIs, stream large responses, and move CPU work to workers.
HTTP caching lets clients and CDNs skip requests entirely:
Cache-Controltells caches how long a response is fresh:public, max-age=300for shared data,private, no-cachefor per-user data that must be revalidated,no-storefor sensitive data.- ETags:
res.send/res.jsonadd a weak ETag by default. When the client sendsIf-None-Matchwith the same tag, Express replies304 Not Modifiedwithout a body. That saves bandwidth, not server work — the handler still ran. express.staticwith longmaxAgeandimmutablefor fingerprinted assets.- Set
Varywhen the response depends on a header (Accept-Encoding,Origin,Authorization).
Application caching saves the work itself: memoise expensive lookups in an in-process LRU with a TTL, or in Redis when several instances must share it. Plan invalidation before you add a cache.
Compression. The compression middleware gzips responses; at scale, let the reverse proxy or CDN do it and skip Node entirely for that work.
Database access dominates most latency: add indexes, select only needed columns, avoid N+1 queries, and use a connection pool.
Measure first: track p95/p99 latency per route, load-test with autocannon, and profile with --cpu-prof before changing code.
Example
import express from "express";
import compression from "compression";
const app = express();
app.use(compression()); // or let nginx/CDN compress
// Tiny TTL cache for an expensive, shared computation
const cache = new Map();
async function cached(key, ttlMs, compute) {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value;
const value = await compute();
cache.set(key, { value, expires: Date.now() + ttlMs });
return value;
}
app.get("/api/leaderboard", async (req, res) => {
const board = await cached("leaderboard", 30_000, async () => {
await new Promise((r) => setTimeout(r, 200)); // pretend: slow aggregate query
return [{ user: "ada", score: 99 }];
});
res.set("Cache-Control", "public, max-age=30"); // browsers/CDNs may reuse for 30 s
res.json(board); // weak ETag -> 304 on If-None-Match
});
app.get("/api/me", (req, res) => {
res.set("Cache-Control", "private, no-store"); // per-user data: never share
res.json({ user: "ada" });
});
app.listen(3000);Remember
- Set NODE_ENV=production and keep sync work off the request path
- Use Cache-Control, ETags and Vary to let clients and CDNs skip work
- Cache expensive results with a TTL; plan invalidation
- Measure p95/p99 and profile before optimising
Watch out for
- Marking per-user responses
public, letting a CDN serve one user's data to another.