Skip to content
Express.js ex performance 5 min read

Caching Strategies

The fastest request your server handles is the one it answers without recomputing anything. Caching trades a little memory and some complexity around freshness for dramatic latency wins, turning slow database reads and expensive renders into constant-time lookups. This page covers the three layers you typically combine in an Express app: an in-process cache for hot data on a single instance, a shared Redis cache using the cache-aside pattern for multiple instances, and HTTP caching headers that let browsers and CDNs skip your server entirely. It closes with the hard part — invalidating stale entries.

In-memory response caching

When you run a single process and the data set is small and hot, the simplest cache is a Map in module scope with a time-to-live. It costs nothing to set up and lives at the speed of memory.

const express = require('express');
const app = express();

const cache = new Map(); // key -> { value, expires }
const TTL = 60_000;      // 60 seconds

function getCached(key) {
  const hit = cache.get(key);
  if (hit && hit.expires > Date.now()) return hit.value;
  cache.delete(key); // expired — drop it
  return null;
}

app.get('/products/:id', async (req, res, next) => {
  const key = `product:${req.params.id}`;
  try {
    const cached = getCached(key);
    if (cached) {
      res.set('X-Cache', 'HIT');
      return res.json(cached);
    }
    const product = await db.findProduct(req.params.id); // slow path
    cache.set(key, { value: product, expires: Date.now() + TTL });
    res.set('X-Cache', 'MISS');
    res.json(product);
  } catch (err) {
    next(err);
  }
});

This works well until you scale out. Each process holds its own copy, so a four-instance deployment caches the same data four times and invalidating one instance leaves the others stale. An unbounded Map is also a memory leak waiting to happen — reach for an LRU cache like lru-cache to cap entries once the data set grows.

Warning: In-memory caches do not survive a restart and are not shared across instances. Treat them as a per-process optimization, not a source of truth.

Cache-aside with Redis

Once you run more than one instance, move the cache out of process into Redis. The dominant pattern is cache-aside (also called lazy loading): the application checks the cache, and on a miss it loads from the database, writes the result back into the cache, then returns it. Redis becomes a fast shared layer that every instance reads and writes.

const { createClient } = require('redis');
const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', (err) => console.error('Redis error', err));
await redis.connect();

const TTL_SECONDS = 300;

app.get('/products/:id', async (req, res, next) => {
  const key = `product:${req.params.id}`;
  try {
    const cached = await redis.get(key);
    if (cached) {
      res.set('X-Cache', 'HIT');
      return res.json(JSON.parse(cached));
    }
    const product = await db.findProduct(req.params.id);
    if (!product) return res.sendStatus(404);

    // EX sets a TTL so stale data eventually self-evicts
    await redis.set(key, JSON.stringify(product), { EX: TTL_SECONDS });
    res.set('X-Cache', 'MISS');
    res.json(product);
  } catch (err) {
    next(err);
  }
});

A quick check with redis-cli confirms the entry and its remaining lifetime:

redis-cli get product:42
redis-cli ttl product:42

Output:

"{\"id\":42,\"name\":\"Desk Lamp\",\"price\":2900}"
(integer) 287

Always set a TTL. It bounds how stale a cached value can get and acts as a safety net even if your explicit invalidation logic has a bug. Pick a window that matches how often the underlying data changes.

HTTP caching headers

The cheapest cache is the one outside your server. With the right response headers, browsers, proxies, and CDNs serve repeat requests without ever reaching Express. Two mechanisms cover most cases: Cache-Control for freshness and ETag for revalidation.

Cache-Control tells clients how long a response may be reused. ETag is a fingerprint of the body; the client sends it back in If-None-Match, and if it still matches you return 304 Not Modified with no body. Express enables weak ETags automatically for responses sent via res.send, so much of this is already working.

app.get('/api/articles/:id', async (req, res, next) => {
  try {
    const article = await db.findArticle(req.params.id);
    if (!article) return res.sendStatus(404);

    res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=30');
    res.json(article); // Express adds an ETag automatically
  } catch (err) {
    next(err);
  }
});

A conditional request that still matches returns an empty 304:

GET /api/articles/7  HTTP/1.1
If-None-Match: "a1b2c3d4"

HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"
Cache-Control: public, max-age=60, stale-while-revalidate=30

For static assets, express.static already emits ETag and Last-Modified. Add long-lived caching for fingerprinted files:

app.use('/assets', express.static('public', {
  maxAge: '1y',
  immutable: true, // file name changes when content changes
}));
DirectiveMeaning
publicAny cache (CDN, proxy) may store the response
privateOnly the end user’s browser may cache it
max-age=NFresh for N seconds before revalidation
no-cacheCache it, but revalidate with the origin every time
no-storeNever cache (use for sensitive data)
immutableContent never changes for this URL — skip revalidation

Invalidation strategies

Knowing when to evict is the genuinely hard part of caching. Three approaches cover most needs, often combined:

  • TTL expiry — let entries expire on their own. Simple and robust, but readers may see data up to one TTL out of date.
  • Write-through / explicit eviction — when you update the record, delete or overwrite its cache key in the same operation so the next read repopulates it.
  • Key versioning — embed a version or updatedAt in the key so old entries become unreachable instead of being deleted.

Explicit eviction on write keeps the cache tight:

app.put('/products/:id', async (req, res, next) => {
  try {
    const updated = await db.updateProduct(req.params.id, req.body);
    await redis.del(`product:${req.params.id}`); // next read is a fresh miss
    res.json(updated);
  } catch (err) {
    next(err);
  }
});

For groups of related keys, namespace them (user:42:orders) and use SCAN to clear a prefix — avoid KEYS in production, since it blocks Redis while it walks the entire keyspace.

Best Practices

  • Always attach a TTL to cached entries so stale data self-evicts even when explicit invalidation fails.
  • Use an in-memory cache only for single-process hot data; switch to Redis the moment you run more than one instance.
  • Prefer cache-aside reads with explicit eviction on writes — it keeps the database authoritative and the cache merely an accelerator.
  • Cache only safe, idempotent GET responses; never cache authenticated, per-user data with public directives.
  • Let HTTP caching and a CDN absorb repeat traffic before it reaches Express; lean on the automatic ETag for cheap 304 responses.
  • Bound in-memory caches with an LRU policy so a hot key set cannot grow into a memory leak.
  • Measure your hit ratio (the X-Cache header makes this easy) and tune TTLs against how often the underlying data actually changes.
Last updated June 14, 2026
Was this helpful?