Caching

Three Layers — Browser, CDN, App

Caching

Cache responses with Cache-Control, in-memory, or Redis. Each layer kills a different latency.

4 min read Level 2/5 #express#caching#performance
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:

  1. BrowserCache-Control: max-age=...
  2. CDN / reverse proxys-maxage=..., varnish
  3. 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 CDN
  • max-age=300 — browser caches for 5 min
  • s-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:

  1. TTL — let the cache expire (simple, sometimes stale)
  2. Manual delete — on write, redis.del("dashboard:42")
  3. 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 →