Skip to content
Architecture

Caching Strategies for SaaS Backends: Redis, HTTP & CDN

Where to put the cache so it actually helps: application vs. HTTP vs. edge, cache invalidation by version not by prayer, and the metrics that tell you the cache is lying.

Tareq Hasan5 min read
On this page

Caching is the only performance tool that gets faster the more you add it and the only one that, mis-architected, doubles your latency while returning stale data with a straight face. The frustrating part is that most SaaS backends are already caching something — and almost none of them measure whether it's the right thing.

This article covers the caching layers in the order your request actually flows through them — HTTP/edge, application, and database — and then the part everybody skips: invalidation and the metrics that tell you the cache is working versus lying to you.

Layer zero: the HTTP cache you already own

Before reaching for Redis, ask whether your response even needs to reach your origin. If a response is public, stable for a known window, and safe to cache by URL, the HTTP cache is the cheapest win available because it happens at the proxy or CDN, far away from your servers and database.

route.ts
export const revalidate = 60; // Next.js: revalidate this route every minute
node-http-cache.ts
// Express-style: set cache headers on a handler
res.setHeader("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
res.setHeader("Vary", "Accept-Encoding");

For a logged-out marketing page, a CDN pop at an edge PoP makes the effective worldwide latency what a datacenter-local cache would only achieve regionally. stale-while-revalidate is the sleeper: it serves the stale copy while a background request refreshes, turning cache stalls into near-zero perceived latency instead of a thundering herd on the origin.

The line you don't cross: session data, per-user payloads, and anything authenticated never rides the public HTTP cache. Those go to the application layer.

Layer one: application-level caching with Redis

The application cache's job is to skip expensive computation or a query entirely. The canonical stack is Redis, because it gives you memory-speed reads, TTLs, and atomic 'get-or-compute' in one tool rather than bolt-on semantics from an in-process Map.

A generic pattern, with the cache stampede guarded:

redis-cache.ts
import { createClient } from "redis";
 
const redis = createClient({ url: process.env.REDIS_URL! });
await redis.connect();
 
const CACHE_MS = 30_000;
 
export async function cached<T>(
  key: string,
  compute: () => Promise<T>,
  ttlMs = CACHE_MS
): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;
 
  // Single-flight: stampede watchdogs run the compute once.
  const result = await compute();
  await redis.set(key, JSON.stringify(result), { PX: ttlMs });
  return result;
}

The stampede guard above is deliberately simple; the hardened version uses SET key value NX PX … with a lock so ten simultaneous cold misses don't all hit the database. For a 30-second TTL and a compute that's re-cacheable in 50ms, the naive version is fine. For a query doing three joins and a .count() on a 20M-row table, the lock is the difference between a cache miss and an outage.

The critical design decision is the key shape:

  • Include every input that changes the output: project:{id}:counts — never dashboard:stats.
  • Version the shape, not the data: stats:v2:{id}. When you change the shape, old keys die naturally by TTL instead of being silently mis-parsed.
  • Namespace per service so deploy-order races can't cross-bleed: web:…, worker:….

Layer two: database answer caching via materialization

The least glamorous but most honest cache layer is at the database: answer queries the database can answer itself, with internal caching semantics. PostgreSQL's BRIN/indexes aside, the two concrete patterns are:

  • Materialized views ("the read replica for reports" from the multi-tenant article) — precomputed, indexed, refreshable on a schedule.
  • Summary tables maintained by the writer: a subscriptions table plus a billing_daily rollup means dashboards read a few thousand rows instead of group-by-ing a billion.

If you ever ask Redis to cache "the result of a GROUP BY that runs every request," you have moved a database problem into an application problem. The GROUP BY belongs in a materialized view or a summary table; Redis caches the rendered thing, not the query.

Invalidation: versioned keys over "clear this prefix"

The single greatest divergence between "we have caching" and "caching works" is invalidation philosophy.

  • Anti-pattern: KEYS dashboard:users:* to find-and-delete after every write. KEYS blocks Redis, and it deletes *while you're still computing the new value — a brief window of staleness with extra work.
  • Pattern: bump the version. Keep one key per cache-able thing that has a version prefix and set the version once on write. Reads do stats:v2:{id}; the version move invalidates everything atomically by targeted delete or by TTL decay.
versioned-invalidate.ts
export const cacheVersion = { dashboard: "v2", feed: "v1" };
 
// On deploy or schema change:
await redis.set("cache:version:dashboard", "v3");

Writes invalidate precisely: DEL stats:{id} on a project update, not a prefix scan. And combine with publishedAt timestamps in payloads so a consumer can judge freshness instead of trusting blindly.

The metrics that tell the truth

A cache prints money until it doesn't, so put a number on it:

  • Hit rate by key (redis.memory.keyspace_hits vs keyspace_misses). A "high hit rate" deserves a look at which keys — a 99% overall can hide one hot-but-wrong key.
  • P95 latency by cache layer. If HTTP-edge is hit-based and app cache is TTl-based, a "speed test" that curls from inside the datacenter measures layer one, not layer zero. Test from multiple PoPs or use RUM.
  • Stale-serve count. Add a stale:true header on SWR-served responses and count them — a rising stale-serve rate with a falling hit rate is your cache missing more, not serving worse.

Caution

Set a hard ceiling on cache size and eviction policy before day one. allkeys-lru with no maxmemory means Redis can evict your hot keys at the exact moment your feature flag flips, and you'll discover it as a latency spike at 3am. maxmemory 256mb + allkeys-lru + monitoring is the baseline, not the finish line.

The decision framework

For any endpoint, ask in order:

  1. Can this response be public and versioned? → HTTP/CDN cache with Cache-Control + revalidate.
  2. Is it per-user, expensive, or computed? → Application cache (Redis), key by input + version, TTL aligned to freshness needs.
  3. Is the computation really a query? → Materialize it in the database; cache the result, never the query body.
  4. What happens on a write? → Delete the exact key, bump the version, update the summary table — not KEYS *.

Caching wins whenever you can make the rare path (invalidation) exact and the common path (read) dumb-fast. Versioned keys, precise deletes, and a measurable hit rate give you that without sacrificing correctness — and they turn "we cache things" into "our cache is load-bearing and verified," which is the only version of caching worth shipping.

Work together

Building something similar?

I design and build production-ready web applications, SaaS platforms and backend APIs — from data modelling to deployment. If a project or technical challenge is on your roadmap, let's talk it through.