Performance & Caching
Once an Express API is correct, the next job is making it fast under load. Most read-heavy endpoints repeat the same expensive work — a database query, an aggregation, a third-party call — for thousands of identical requests. Caching stores the computed result close to the app so repeat requests skip that work entirely, cutting p95 latency from hundreds of milliseconds to single digits and taking pressure off your database. In this lesson you will add a Redis cache, compress responses, protect the API with rate limiting, and lean on HTTP caching headers — the same layered approach production APIs use.
- Measure baseline latency and throughput before optimizing
- Implement a cache-aside layer with Redis and ioredis
- Invalidate cache keys safely on writes
- Add response compression and distributed rate limiting
- Use HTTP caching headers to offload the client and CDN
Measure Before You Optimize
Never guess where the time goes. Establish a baseline with a load-testing tool so every change is backed by numbers. autocannon fires concurrent requests and reports latency percentiles and requests per second.
bash# 50 concurrent connections for 10 seconds npx autocannon -c 50 -d 10 http://localhost:3000/api/products
Focus on the tail
Caching with Redis
Redis is an in-memory key-value store that reads and writes in microseconds, which makes it the standard backing store for API caches. The ioredis client gives you promises, connection pooling, and cluster support. Configure it to fail loudly and cap retries so a Redis outage never hangs a request.
javascriptimport Redis from "ioredis" const redis = new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: 3, enableReadyCheck: true, }) redis.on("error", (err) => console.error("Redis connection error:", err))
The Cache-Aside Pattern
Cache-aside (lazy loading) is the most common strategy: the app checks the cache first, and only on a miss does it hit the database and populate the cache. The middleware below intercepts res.json, stores the serialized body with a TTL, and short-circuits future requests. Crucially, it fails open — if Redis is unreachable, the request still reaches your handler instead of erroring.
javascriptfunction cache(ttlSeconds = 60) { return async (req, res, next) => { if (req.method !== "GET") return next() const key = `cache:${req.originalUrl}` try { const hit = await redis.get(key) if (hit) { res.setHeader("X-Cache", "HIT") return res.type("application/json").send(hit) } } catch { return next() // Redis down → skip cache, serve normally } res.setHeader("X-Cache", "MISS") const sendJson = res.json.bind(res) res.json = (body) => { if (res.statusCode === 200) { redis.set(key, JSON.stringify(body), "EX", ttlSeconds).catch(() => {}) } return sendJson(body) } next() } } app.get("/api/products", cache(120), async (req, res) => { const products = await prisma.product.findMany() res.json(products) })
Cache Invalidation
Stale data is the classic caching bug. When the underlying data changes, evict the affected keys. Use SCAN (never KEYS, which blocks the entire Redis server) to remove keys by pattern, and pipeline the deletes for efficiency.
javascriptasync function invalidate(pattern) { const stream = redis.scanStream({ match: pattern, count: 100 }) const pipeline = redis.pipeline() for await (const keys of stream) { for (const key of keys) pipeline.del(key) } await pipeline.exec() } app.post("/api/products", requireAuth, async (req, res) => { const product = await prisma.product.create({ data: req.body }) await invalidate("cache:/api/products*") res.status(201).json(product) })
Warning
KEYS * scans the whole keyspace and blocks Redis until it finishes — it can freeze production. Always use SCAN / scanStream for pattern matching against a live server.Compression
Compressing response bodies with gzip or Brotli shrinks JSON and HTML payloads by 60-80%, which is often the single biggest win for perceived speed. Add the compression middleware early in the stack and set a threshold so tiny responses aren't compressed (the CPU cost isn't worth it below ~1 KB).
javascriptimport compression from "compression" app.use( compression({ threshold: 1024, // only compress responses larger than 1 KB filter: (req, res) => { if (req.headers["x-no-compression"]) return false return compression.filter(req, res) }, }) )
Offload it to the edge
Rate Limiting
Rate limiting protects your API from abuse, scrapers, and accidental request storms. express-rate-limit counts requests per client, but its default in-memory store breaks across multiple instances — each process keeps its own counter. Back it with Redis via rate-limit-redis so the limit is shared cluster-wide.
javascriptimport rateLimit from "express-rate-limit" import { RedisStore } from "rate-limit-redis" const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // 100 requests per window per IP standardHeaders: true, // send RateLimit-* headers legacyHeaders: false, store: new RedisStore({ sendCommand: (...args) => redis.call(...args), }), }) app.use("/api/", apiLimiter)
Tighter limits on sensitive routes
max: 5 per 15 min) to /api/auth/login and password-reset endpoints to slow down credential-stuffing attacks.HTTP Caching Headers
The fastest request is the one your server never handles. Cache-Control and ETag headers let browsers and CDNs cache responses themselves. Express computes weak ETags automatically and answers with 304 Not Modified when the client's cached copy is still valid. For public, rarely-changing data, add an explicit Cache-Control.
javascriptapp.get("/api/config", (req, res) => { res.set("Cache-Control", "public, max-age=300, stale-while-revalidate=60") res.json({ features: { darkMode: true }, version: "3.1.0" }) })
stale-while-revalidate lets the CDN serve a slightly stale response instantly while it refreshes in the background — no user ever waits on the origin. Use private, no-store for anything user-specific so it is never cached by a shared proxy.
Common Pitfalls
Warning
GET, HEAD). Never cache responses to POST, PATCH, or DELETE — they mutate state and their responses are request-specific.Next Steps
Next Lesson
Microservices Architecture