Three Layers — Browser, CDN, App
Caching
Cache responses with Cache-Control, in-memory, or Redis. Each layer kills a different latency.
What you'll learn
- Set Cache-Control correctly
- Cache reads in Redis
- Invalidate on writes
The fastest endpoint is one that doesn’t run. Three places to cache, ordered by hop-distance from the client:
- Browser —
Cache-Control: max-age=... - CDN / reverse proxy —
s-maxage=..., varnish - App layer — Redis, in-memory
Browser & CDN Caching
app.get("/api/static/config.json", (req, res) => {
res.set("cache-control", "public, max-age=300, s-maxage=600");
res.json(staticConfig);
}); public— caches in the CDNmax-age=300— browser caches for 5 mins-maxage=600— CDN caches for 10 min
For never-changing assets (fingerprinted JS/CSS):
app.use("/assets", express.static("dist/assets", { maxAge: "1y", immutable: true })); ETag (Conditional GET)
Express enables ETag by default. The client sends
If-None-Match: <etag>; if unchanged, Express returns 304 Not Modified with an empty body — saving bandwidth.
For dynamic API endpoints, set etag yourself:
const tag = `"${hash(JSON.stringify(data))}"`;
res.set("etag", tag);
if (req.headers["if-none-match"] === tag) {
return res.status(304).end();
}
res.json(data); Redis as App Cache
For computed values that are expensive to produce:
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
export async function getDashboard(userId) {
const key = `dashboard:${userId}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await computeDashboard(userId);
await redis.set(key, JSON.stringify(data), "EX", 60); // 60s TTL
return data;
} Useful for:
- Expensive DB joins
- API responses you can’t control (rate-limited 3rd parties)
- Page render output
Invalidation — The Hard Part
There are only two hard things in Computer Science: cache invalidation and naming things.
Three strategies:
- TTL — let the cache expire (simple, sometimes stale)
- Manual delete — on write,
redis.del("dashboard:42") - Versioning — bump a version, keys become irrelevant
Most pragmatic combo: short TTL + manual delete on writes. Worst case the user sees N-second stale data.
In-Memory Cache (Single Process Only)
import { LRUCache } from "lru-cache";
const cache = new LRUCache({ max: 1000, ttl: 60_000 });
function getCached(key, compute) {
const hit = cache.get(key);
if (hit) return hit;
const value = compute();
cache.set(key, value);
return value;
} LRU caches are perfect when the value is expensive but small. Each process has its own cache — fine if you don’t need cross-instance consistency.
Don’t Cache What You Can’t Verify
A cache that’s wrong is worse than no cache. If you can’t easily detect stale data, prefer a short TTL — the cost of one cache miss is small.
Background Jobs →