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.
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.
export const revalidate = 60; // Next.js: revalidate this route every minute// 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:
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— neverdashboard: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
subscriptionstable plus abilling_dailyrollup 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.KEYSblocks 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.
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_hitsvskeyspace_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:trueheader 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:
- Can this response be public and versioned? → HTTP/CDN cache with
Cache-Control+revalidate. - Is it per-user, expensive, or computed? → Application cache (Redis), key by input + version, TTL aligned to freshness needs.
- Is the computation really a query? → Materialize it in the database; cache the result, never the query body.
- 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.
Related reading
Multi-Tenant Database Strategies: Row-Level Security vs. Schema-per-Tenant
Shared schema, schema-per-tenant and a hybrid mix of both — how to choose a multi-tenant database model from day one, with real trade-offs for PostgreSQL, row-level security and analytics.
PostgreSQL Connection Pooling With PgBouncer: A Practical Guide
Why Node.js apps exhaust PostgreSQL connections, how transaction pooling with PgBouncer fixes it, and the gotchas — prepared statements, set_config, and monitoring — that trip teams up.
A Practical Guide to PostgreSQL Indexing
B-tree, GIN, partial and composite indexes — how PostgreSQL actually uses them, when each earns its disk, and the questions to ask before adding another index.
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.