advanced
backend

Performance & Caching

Redis caching, compression, rate limiting, and response optimization

express fundamentals
26 min read

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.

Learning Objectives
  • 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

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.

javascript
import 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.

javascript
function 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) })
App reads cache first, loads from DB on a miss, then populates the cache. Simple and resilient; the first request after expiry is slow (a "cold" miss).

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.

javascript
async 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) })

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).

javascript
import 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) }, }) )

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.

javascript
import 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)

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.

javascript
app.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


Next Steps

Continue Learning
Split services and communicate via HTTP.

Next Lesson

Microservices Architecture