Skip to content

Caching in Express Applications

Caching avoids repeating expensive work, a slow database query, a costly computation, by storing the result and serving it again for subsequent requests. This lesson covers the main caching layers available to an Express app.

Where Caching Can Happen

Caching can live at several layers: in application memory (fast, but per-process and lost on restart), in a shared store like Redis (survives restarts, shared across instances), or at the HTTP layer via response headers (letting browsers and CDNs skip requesting your server at all).

The right layer depends on how expensive the underlying operation is, how often the data changes, and whether multiple server instances need to share the same cached value.

const cache = new Map();

app.get('/products/:id', async (req, res, next) => {
  const cacheKey = `product:${req.params.id}`;
  if (cache.has(cacheKey)) {
    return res.json(cache.get(cacheKey));
  }

  try {
    const product = await Product.findById(req.params.id);
    cache.set(cacheKey, product);
    res.json(product);
  } catch (err) {
    next(err);
  }
});

This simple Map-based cache works for a single-process app, but is lost on restart and not shared across multiple instances.

Caching With Redis

const redisClient = require('../redis');

const cached = await redisClient.get(cacheKey);
if (cached) return res.json(JSON.parse(cached));

const product = await Product.findById(id);
await redisClient.set(cacheKey, JSON.stringify(product), { EX: 3600 });
res.json(product);
  • EX: 3600 sets a time-to-live (in seconds) so cached data expires automatically after an hour.
  • Redis is shared across server instances, unlike an in-process Map.
  • Cache keys should be specific enough to avoid collisions between unrelated data.
  • Always define an eviction/expiration strategy, unbounded caches eventually exhaust memory.

Caching Strategies Cheatsheet

A comparison of caching approaches for different needs.

Approach Shared Across Instances? Survives Restart? Best For
In-memory (Map) No No Single-instance apps, short-lived data
Redis Yes Yes Multi-instance apps, shared cached data
HTTP cache headers N/A (client/CDN-side) N/A Static or rarely-changing public responses
CDN caching Yes (edge network) Yes Public, cacheable API/content responses

HTTP Cache Headers

For public, non-personalized responses, HTTP cache headers let browsers and CDNs skip contacting your server entirely for repeat requests, the fastest possible cache: no request at all.

app.get('/public/config', (req, res) => {
  res.set('Cache-Control', 'public, max-age=3600');
  res.json({ featureFlags: { newCheckout: true } });
});

Never apply public caching to personalized or authenticated responses, doing so can leak one user's data to another.

Cache Invalidation

The hardest part of caching is knowing when to remove or refresh a cached value once the underlying data changes. A common pattern invalidates (deletes) the relevant cache key immediately after any write to that data.

async function updateProduct(id, data) {
  const product = await Product.findByIdAndUpdate(id, data, { new: true });
  await redisClient.del(`product:${id}`); // invalidate stale cache
  return product;
}

Common Mistakes

  • Caching personalized or authenticated data with public HTTP cache headers, leaking data between users.
  • Never invalidating a cache after the underlying data changes, serving stale results indefinitely.
  • Using an in-process cache for data that needs to be consistent across multiple server instances.
  • Caching everything with no expiration, letting memory usage grow without bound.

Key Takeaways

  • Caching can live in application memory, a shared store like Redis, or the HTTP layer itself.
  • Redis is required when multiple server instances need to share the same cached data.
  • HTTP cache headers let browsers and CDNs skip hitting your server entirely for cacheable responses.
  • Every cache needs a clear invalidation or expiration strategy to avoid serving stale data.

Pro Tip

Always set an expiration (EX in Redis, max-age in HTTP headers) on every cache entry, even a long one, an unbounded cache with no invalidation plan quietly turns into a source of stale-data bugs.