Performance and HTTP caching

Express · Advanced · 14 min

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-Control tells caches how long a response is fresh: public, max-age=300 for shared data, private, no-cache for per-user data that must be revalidated, no-store for sensitive data.
  • ETags: res.send/res.json add a weak ETag by default. When the client sends If-None-Match with the same tag, Express replies 304 Not Modified without a body. That saves bandwidth, not server work — the handler still ran.
  • express.static with long maxAge and immutable for fingerprinted assets.
  • Set Vary when 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.

Check yourself

  1. A client sends If-None-Match with the current ETag of a JSON response. What does Express do by default?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/advanced/best-practice-performance