Every read-heavy system eventually puts a caching layer in front of its database, and every team that does discovers the same truth: the cache is not a performance detail, it is a distributed-systems component with its own consistency model. Redis in front of PostgreSQL can absorb the bulk of read traffic and flatten p99 latency — and it can also serve a stale price for hours, melt down in a thundering herd, or quietly become the system of record nobody intended it to be.
The difference between those outcomes is a handful of patterns that are well understood but rarely applied together. None require exotic infrastructure; all require deciding, per key family, what staleness you can tolerate and what happens when the cache is wrong.
Cache-Aside Is a Consistency Decision
The default pattern — check Redis, miss, read Postgres, populate Redis with a TTL — is simple and mostly right, but its failure mode is subtle. Between the database read and the cache write, another writer can update the row, leaving the cache holding pre-update data until the TTL expires. For a session preference, irrelevant. For an account balance, a bug report.
Be explicit about tolerance:
- ▸A staleness budget per key family: user profiles might tolerate five minutes; feature flags, thirty seconds; anything transactional probably should not be cached at all, or only with the strictest invalidation you have.
- ▸Delete, do not update, on write: after a database write, delete the cache key rather than writing the new value. Deletion is idempotent and immune to write-ordering races; the next reader repopulates from the source of truth.
- ▸TTL as backstop, never as mechanism: an aggressive TTL is not an invalidation strategy — it is a cap on how long a bug lasts.
Stampedes and Single-Flight
Hot keys expire, and when they do, every concurrent request misses simultaneously and stampedes the database the cache was hiding load from. The classic incident shape: a popular key's TTL lapses at peak traffic and Postgres receives a wall of identical queries in the same second.
Three defenses stack well. Add jitter to TTLs so keys in the same family do not expire in lockstep. Use single-flight semantics: the first miss takes a short Redis lock — SET with NX and an expiry — and rebuilds the value while other requests briefly serve the stale copy or wait. For the hottest keys, use probabilistic early refresh: recompute shortly before expiry with a probability that rises as the deadline approaches, so one background request renews the value instead of a crowd hitting the cliff edge together.
Invalidation That Actually Works
Application-side invalidation breaks the moment writes bypass the application — a migration script, an admin tool, another service touching the same table. If correctness matters, drive invalidation from the database itself: a Debezium change-data-capture stream on the relevant tables, feeding a small consumer that deletes affected keys, turns invalidation from a per-code-path convention into a guarantee.
Versioned keys are the other robust trick: include a version in the key itself, bumped in one place on every write. Old keys become garbage that expiry collects, and there is no delete race at all. The cost is an extra lookup to resolve the current version — which is itself cacheable with a short TTL, since a stale version pointer only causes a harmless extra miss.
Operating Redis Like a Database
Redis fails like a database, so operate it like one. Set maxmemory and choose the eviction policy deliberately — allkeys-lru for a pure cache, volatile-ttl when some keys must never evict. Know your persistence stance: for a true cache, disabling AOF is reasonable, but then plan for the warm-up stampede after every restart. Replicate for availability and remember replication is asynchronous — a failover can lose recent writes, which is one more reason the cache must never be the only home of any data. Watch for oversized values and celebrity keys; a single huge hash or one viral entity can dominate a node's CPU and bandwidth.
Before shipping any new cache, run this checklist:
1. Name the staleness budget for the key family and get the product owner to agree to it in writing. 2. Define the invalidation path — and what happens when that path itself fails. 3. Add TTL jitter and single-flight before launch, not after the first stampede. 4. Confirm the system behaves correctly — slower, but correctly — with Redis completely down. 5. Ship a hit-rate and origin-load dashboard so the cache can prove it still earns its complexity.
A caching layer designed this way turns infrastructure spend directly into user experience: origin databases shrink, tail latency stabilizes, and traffic spikes become absorbable events rather than incidents. Just as importantly, the failure modes are bounded and known in advance — which means the cache accelerates the roadmap instead of generating a steady tax of correctness bugs that erode trust in the product.
