+1 (726) 227-3745

Caching a MEAN App Properly: ETags in Express 5, Read-Through Redis, and Angular 22 Resources

Most MEAN performance work we are called in for ends the same way: the indexes are fixed, the aggregation pipeline is sane, the queries run in single-digit milliseconds — and the API is still slow at peak because it is doing the same correct work thousands of times a second. That is a caching problem, not a query problem.

This tutorial builds the three caching layers we put into client MEAN apps, in the order we add them: HTTP caching at the edge of Express 5, a Redis read-through cache in front of Mongoose 8, and request-level caching in an Angular 22 client. It also covers the part most teams get wrong — invalidation — and the pitfalls that turn a cache into a data-leak incident.

Versions used: Node.js 24 LTS, Express 5, Mongoose 8 on MongoDB 8, Redis 7, Angular 22.

Rule zero: decide what is cacheable before you cache

Before writing any code, classify every endpoint into one of three buckets:

  • Public and shared — product catalogues, published articles, reference data. Cacheable everywhere, including shared proxies and CDNs.
  • Private and per-user — dashboards, carts, anything behind auth. Cacheable in Redis under a user-scoped key and in the browser, never in a shared proxy.
  • Never cache — payments, tokens, one-shot mutations, anything whose staleness has a legal or financial cost.

Write that classification into a table in your repo. Every rule below depends on it, and a cache that cannot say which bucket a response belongs to will eventually serve user A's dashboard to user B.

Layer 1: HTTP caching in Express 5

The cheapest cache is the one where the response never leaves your process. Express 5 ships strong ETags by default, but res.json() still serialises and hashes the body, and by default nothing tells the browser it may reuse anything.

Start by setting explicit cache policy per route rather than globally:

// api/src/cache-headers.js
export const publicCache = (maxAge, swr = 60) => (req, res, next) => {
  res.set('Cache-Control', `public, max-age=${maxAge}, stale-while-revalidate=${swr}`);
  next();
};

export const privateCache = (maxAge) => (req, res, next) => {
  res.set('Cache-Control', `private, max-age=${maxAge}`);
  res.vary('Authorization');
  next();
};

export const noStore = (req, res, next) => {
  res.set('Cache-Control', 'no-store');
  next();
};

res.vary('Authorization') is not optional on private routes. Without it, any intermediate cache is entitled to treat two different users' requests as the same request.

Now use conditional requests so unchanged resources cost you a 304 instead of a payload:

import express from 'express';
import { Article } from './models.js';
import { publicCache } from './cache-headers.js';

const router = express.Router();

router.get('/articles/:slug', publicCache(60, 300), async (req, res) => {
  const article = await Article.findOne({ slug: req.params.slug }).lean();
  if (!article) return res.status(404).json({ error: 'not_found' });

  // Weak validator derived from the document, not the serialised body.
  res.set('ETag', `W/"${article._id}-${article.updatedAt.getTime()}"`);
  res.set('Last-Modified', article.updatedAt.toUTCString());

  if (req.fresh) return res.status(304).end();
  res.json(article);
});

export default router;

req.fresh compares the request's If-None-Match / If-Modified-Since against the headers you just set, so a returning client gets an empty 304. Deriving the ETag from updatedAt instead of the body means you skip serialisation entirely on the hit path.

A caveat for MEAN teams migrating from Express 4: in Express 5 res.send no longer accepts a status-code argument, and errors thrown in async handlers now reach your error middleware automatically — so a cache middleware that wraps res.json must be written to tolerate an error path that never calls it.

Layer 2: a read-through Redis cache in front of Mongoose 8

HTTP caching does nothing for the first request after a deploy, for server-rendered pages, or for internal callers. That is what Redis is for.

npm install ioredis
// api/src/cache.js
import Redis from 'ioredis';

export const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');

const inflight = new Map();

export async function cached(key, ttlSeconds, loader) {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit);

  // Single-flight: collapse concurrent misses for the same key into one query.
  if (inflight.has(key)) return inflight.get(key);

  const promise = (async () => {
    const value = await loader();
    if (value !== undefined) {
      // Jitter the TTL so a burst of keys written together does not expire together.
      const ttl = ttlSeconds + Math.floor(Math.random() * ttlSeconds * 0.2);
      await redis.set(key, JSON.stringify(value), 'EX', ttl);
    }
    return value;
  })().finally(() => inflight.delete(key));

  inflight.set(key, promise);
  return promise;
}

Two details that matter more than the caching itself. Single-flight stops a cache stampede: when a hot key expires under load, one request repopulates it and the other 500 wait on the same promise instead of all hitting MongoDB. TTL jitter stops synchronised expiry, the failure mode where everything cached during a deploy expires in the same second.

Usage, with .lean() so you cache plain objects rather than hydrated Mongoose documents:

router.get('/categories', publicCache(120), async (req, res) => {
  const categories = await cached('categories:v1:all', 300, () =>
    Category.find({ active: true }).sort({ name: 1 }).lean()
  );
  res.json(categories);
});

Key design and invalidation

Bake three things into every key: a namespace, a schema version, and the scope.

articles:v2:slug:getting-started      // public
dashboard:v1:user:663f...:summary     // private, user-scoped

The v2 is your escape hatch. When the shape of the cached object changes, bump the version in code and every old entry becomes unreachable and expires on its own — no flush, no deploy-time race where new code reads old-shaped JSON.

For explicit invalidation, drive it from the write path, not from a timer. A Mongoose middleware keeps it next to the model:

articleSchema.post('save', async function (doc) {
  await redis.del(`articles:v2:slug:${doc.slug}`, 'articles:v2:list:latest');
});

articleSchema.post('findOneAndUpdate', async function (doc) {
  if (doc) await redis.del(`articles:v2:slug:${doc.slug}`, 'articles:v2:list:latest');
});

Never use KEYS to find things to delete — it blocks the Redis event loop. If you need group invalidation, keep a Redis Set of member keys per tag and delete the set's members, or use a version counter per tag and include it in the key.

If writes reach MongoDB from somewhere other than this API — an admin tool, a migration script, another service — hook invalidation to a MongoDB 8 change stream instead, so the cache follows the database rather than one writer.

Layer 3: the Angular 22 client

Browser-side, the win is not re-fetching data you already have during a single user session. Angular 22's httpResource gives you a signal-based fetch that you can share across components:

@Injectable({ providedIn: 'root' })
export class CategoryStore {
  readonly categories = httpResource<Category[]>(() => '/api/categories');
}

Because the resource lives in a root-provided service, twenty components that inject it share one HTTP request and one signal, and the response is still subject to the Cache-Control headers you set in layer 1.

For SSR, the important one is transfer state. If your app is server-rendered on Express 5, withHttpTransferCacheOptions stops the browser from immediately repeating every request the server already made:

provideClientHydration(
  withIncrementalHydration(),
  withHttpTransferCacheOptions({ includePostRequests: false, filter: (req) => req.url.startsWith('/api/') })
);

Leave includePostRequests off unless you have audited every POST for user-specific data — transfer state is embedded in the HTML and shipped to the browser.

Measuring it

Cache work without numbers is decoration. Track three metrics and put them on one dashboard:

  1. Hit ratio per key namespace. Below roughly 70% on a hot namespace, your TTL is too short or your key is too specific.
  2. Origin p95 — latency of the loader function only. This tells you what a miss actually costs and whether the cache is hiding a query that still needs an index.
  3. Stale-serve count on stale-while-revalidate routes, so you know how often users see old data.

If you already run OpenTelemetry, wrap cached() in a span with a cache.hit attribute and you get all three from traces you are already collecting.

Pitfalls we see in audits

  • Caching authenticated responses in a shared layer. Public Cache-Control on a route that reads req.user is the single most common cause of cross-user data leaks in Node APIs.
  • Caching errors. A loader that returns null on a transient failure will pin that failure for the whole TTL. Cache negative results deliberately, with a short TTL, and never cache 5xx.
  • Caching Mongoose documents. Serialising a hydrated document and re-reading it gives you a plain object that no longer has your methods or getters. Use .lean() and cache the shape you actually serve.
  • Unbounded memory. Set a maxmemory and an allkeys-lru policy on Redis. An eviction-free cache is a memory leak with extra steps.
  • Cache as a database. If losing Redis takes your app down rather than making it slow, you have built a dependency, not a cache. Test that path in staging.

Where to start on Monday

Pick your single hottest read endpoint. Add layer 1 headers and an ETag to it, measure, then add cached() with a 60-second TTL and versioned key, and wire invalidation into the write path for that collection only. One endpoint done properly will usually take more load off MongoDB than a cache sprinkled across twenty.

If you would like a second pair of eyes on a MEAN app that is slow under load — or a caching and invalidation design reviewed before it ships — get in touch. Performance tuning in the MEAN stack is one of the things we do most.