Caching is easy to add and surprisingly hard to get right. The performance wins are real, but so are the failure modes: stale reads, stampedes, and “why is production showing old data?” tickets. This guide breaks down the core caching patterns—read-through, write-through, and practical cache busting—with decision rules, pitfalls, and copy/paste-ready snippets.
Quickstart
Want fast wins without turning your system into a cache-themed escape room? Use these steps in order. They prioritize correctness and operational sanity over “maximum hit rate.”
1) Pick a pattern based on your data
A cache is not a database. Decide how much staleness you can tolerate and what the source of truth is.
- Mostly reads, occasional writes: read-through / cache-aside + TTL
- Writes must reflect immediately: write-through or write + invalidate
- Bulk updates / batch jobs: versioned keys or targeted busting
2) Define considered defaults (today)
“No TTL” and “TTL = 5 minutes everywhere” are both traps. Use sensible, explicit defaults.
- Key naming:
app:env:entity:id[:vX] - TTL per data type (user profile ≠ stock quote)
- Serialization format (JSON, msgpack) and versioning
- Bounded cache size + eviction awareness
3) Add stampede protection
One hot key can melt your database when it expires. Prevent the thundering herd.
- Use TTL jitter (randomize expiry slightly)
- Single-flight refresh (one request repopulates)
- Optional: stale-while-revalidate for safe data
4) Decide your invalidation strategy
If you can’t explain how data leaves the cache, you don’t have a strategy—you have hope.
- TTL-only: simplest, allows bounded staleness
- Write invalidate: delete affected keys on update
- Versioned keys: bump version to “instant bust” whole groups
Optimize for correctness first, then latency, then hit rate. A slightly lower hit rate with predictable behavior beats a “perfect” cache that occasionally lies.
Overview
“Caching Patterns: Read-Through, Write-Through, and Busting” is really about one question: where do you want the complexity to live? You can hide complexity in the application, push it into a caching layer, or avoid it by accepting bounded staleness.
What this post covers
- How read-through (aka cache-aside) works, and why it’s the default choice
- When write-through makes sense (and when write + invalidate is safer)
- Three “busting” approaches: TTL, targeted invalidation, and versioned keys
- Common failure modes: stale data, stampedes, cache drift, and unbounded key growth
- Operational checklists so your cache stays an optimization, not a dependency
| Pattern | Who owns cache logic? | Best for | Main risk |
|---|---|---|---|
| Read-through | Application (on reads) | Read-heavy services, simple correctness rules | Stampedes on expiry, stale if invalidation missing |
| Write-through | Application (on writes) | Must reflect writes quickly, high cache hit needed | Write amplification, partial failures (DB vs cache) |
| Busting | Policy (TTL/invalidate/version) | Keeping cached views aligned with source | Over-busting (low hit rate) or under-busting (stale data) |
Different teams use different names. In this article, “read-through” describes the behavior users see (“reads go through the cache first”), and the implementation shown is the common cache-aside approach.
Core concepts
Before you pick a caching pattern, align on a mental model. A cache is a derived copy of data that is safe to discard. The source of truth (database, service, API) is still responsible for correctness.
The caching triangle: latency, correctness, simplicity
You rarely get all three. Most caching designs are choosing two:
- Low latency + simple: TTL-only (accept bounded staleness)
- Low latency + correct: write-through or write + invalidate (more logic)
- Correct + simple: no cache (or cache only immutable data)
Key definitions you’ll use everywhere
Read-through (cache-aside)
On read: check cache → if miss, read from source → populate cache → return. Writes go to the source of truth; cache is populated on demand.
- Default pattern for many services
- Easy to roll out incrementally
- Needs a plan for invalidation and stampedes
Write-through
On write: update the source of truth and the cache (or treat the cache update as part of the write path). Reads then hit cache more reliably.
- Good for read-after-write expectations
- Higher write cost (extra network + serialization)
- Partial failures must be handled carefully
Cache busting: TTL, invalidation, and versioned keys
Three ways cached data “goes away”
| Method | How it works | Where it shines | Watch out for |
|---|---|---|---|
| TTL | Entries expire after time | Bounded staleness is acceptable | Stampedes, “why 5 minutes?” guessing |
| Targeted invalidation | Delete keys that changed | Specific entities (user, product) update | Hard with derived views (lists, aggregates) |
| Versioned keys | Bump a version prefix so old keys are ignored | Batch changes, “invalidate a whole family” | Old keys linger until eviction/TTL |
Cache stampede (thundering herd)
A stampede happens when many requests simultaneously miss the cache (often right after an expiry) and all hit the database. The fix is not “increase TTL” (though it helps); it’s to add a coordination mechanism: a lock, single-flight, or stale-while-revalidate behavior for safe data.
Stale data isn’t only about TTL. It’s also about inconsistent invalidation: you update a user, but forget to invalidate their “profile”, their “settings”, and the “team member list” view. If you cache derived views, budget time for invalidation design.
Step-by-step
Below is a practical implementation path used in many production systems: start with read-through for safe wins, introduce write-through (or invalidate-on-write) only where it matters, then add a busting strategy that matches your data model.
Step 0 — Decide what you’re caching
- Cache immutable or slow-changing data first: configs, feature flags snapshots, public profiles
- Define freshness expectations: “must update immediately” vs “OK within 60s”
- Choose the cache tier: in-process (fast, per-instance) vs shared (Redis/Memcached)
- Write down key space: key prefixing, cardinality, and max size expectations
Step 1 — Implement read-through (cache-aside) with sane TTL
Read-through is the best default because you can ship it incrementally: add cache reads, measure hit rate, and you haven’t changed write semantics. The two things you must handle from day one are (1) TTL jitter and (2) stampede protection.
Pick TTL from the business meaning of freshness, not from performance vibes. If a “profile” can be 5 minutes old, TTL can be 5 minutes. If it must be fresh after updates, plan invalidation.
import json
import random
import time
from typing import Any, Dict, Optional
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def db_get_user(user_id: str) -> Dict[str, Any]:
# Replace with your DB call
return {"id": user_id, "name": "Ada", "plan": "pro", "updated_at": int(time.time())}
def get_user_read_through(user_id: str) -> Dict[str, Any]:
"""
Read-through (cache-aside):
1) Try cache
2) On miss, load from DB
3) Populate cache with TTL (+ jitter)
Includes lightweight stampede protection via a short lock.
"""
key = f"app:prod:user:{user_id}"
lock_key = f"{key}:lock"
ttl_seconds = 300 # 5 minutes, example
ttl_jitter = random.randint(0, 30) # spread expirations to reduce herd
cached: Optional[str] = r.get(key)
if cached:
return json.loads(cached)
# Try to become the "refresher"
got_lock = r.set(lock_key, "1", nx=True, ex=10)
if not got_lock:
# Someone else is refreshing; brief wait then try cache again
time.sleep(0.05)
cached2 = r.get(key)
if cached2:
return json.loads(cached2)
# Fallback: don't block forever; go to DB as last resort
data = db_get_user(user_id)
r.set(key, json.dumps(data), ex=ttl_seconds + ttl_jitter)
r.delete(lock_key)
return data
What to watch out for (read-through)
- Negative caching: if “not found” is common, cache it briefly (but keep TTL short)
- Serialization drift: version your cached payload if schemas evolve
- Key explosion: avoid caching unbounded queries (e.g., raw search strings) without limits
- Monitoring: track hit rate, P95 latency, and DB load before/after caching
Step 2 — Add write-through (or write + invalidate) for read-after-write paths
If your product expects a user to see their changes immediately (settings, checkout, account state), TTL-only caching can feel “buggy.” You have two practical options:
Option A: Write-through
Update source of truth and cache in the write path so subsequent reads hit fresh cache.
- Higher write latency (extra network round trip)
- Must handle partial failure (DB succeeds, cache fails)
- Great for entity reads where key = entity ID
Option B: Write + invalidate
Update source of truth, then delete affected keys so the next read repopulates fresh data.
- Often simpler and safer than “update cache”
- Good when cached views are derived or hard to rebuild correctly
- Can cause a short-term miss spike (mitigate with locks)
// Write-through example (Node/Express-style pseudocode).
// Goal: after updating the DB, keep the cache aligned for read-after-write flows.
async function updateUser(req, res) {
const userId = req.params.userId;
const patch = req.body;
const cacheKey = `app:prod:user:${userId}`;
// 1) Update the source of truth first.
// In real code: wrap this in a transaction if multiple tables are involved.
const updated = await db.users.update(userId, patch);
// 2) Best-effort cache update.
// If this fails, correctness is still preserved (DB is truth); cache will heal on next read.
try {
const ttlSeconds = 300;
await redis.set(cacheKey, JSON.stringify(updated), { EX: ttlSeconds });
} catch (err) {
// Log and move on: don't fail the write just because the cache hiccupped.
logger.warn({ err, userId }, "cache_update_failed");
}
return res.json(updated);
}
If your system can’t write when Redis is down, you’ve accidentally promoted the cache into a database. Prefer best-effort cache updates and a read-through fallback that heals.
Step 3 — Cache busting that actually stays correct
Cache busting is the part most teams underestimate. A single entity is easy to invalidate; derived views are not. If you cache: lists (team members), aggregates (counts), or composed pages (dashboard), you need a strategy that doesn’t require remembering twenty keys on every write.
Use versioned keys for groups of related cache entries
Store a small “version number” per entity or per collection. Your cache keys include the version. To bust, bump the version. Old keys naturally become unreachable (and expire later).
- Great for: “user dashboard vX”, “project list vX”, “catalog vX”
- Works well with: TTL (old keys eventually disappear)
- Avoids: trying to enumerate and delete every derived key
# Versioned key "busting" using Redis.
# Pattern:
# - Keep a version counter per group (e.g., user dashboard)
# - Read uses the current version in the key
# - Busting increments the version (old keys become obsolete)
# Read path (conceptual):
# v=$(redis-cli GET app:prod:user:42:dash:ver || echo 1)
# key="app:prod:user:42:dash:v${v}"
# redis-cli GET "$key"
# Bust path (on writes that affect the dashboard):
redis-cli INCR app:prod:user:42:dash:ver
# Optional hygiene: ensure the version key itself expires if users go inactive
# (choose a long TTL that matches your product usage patterns)
redis-cli EXPIRE app:prod:user:42:dash:ver 2592000 # 30 days
When TTL-only is enough
- Data is informational, not transactional
- Users accept bounded staleness (seconds/minutes)
- You can tolerate “eventual” correctness
- You want the simplest operational model
When you need explicit busting
- Read-after-write must be accurate
- Stale data has a user-visible or financial cost
- You cache derived views (lists, dashboards)
- Batch jobs rewrite lots of underlying data
Track cache invalidations or version bumps as first-class events (logs/metrics). If hit rate drops after a deploy, you should be able to tell whether it’s a traffic shift or a busting bug.
Common mistakes
Most caching incidents look like “stale data” or “database overload,” but the root cause is usually a predictable design miss. Here are the classics—plus fixes you can apply without a rewrite.
Mistake 1 — Caching a derived view without an invalidation plan
Lists and dashboards are the hardest to keep fresh. TTL-only may be fine, but decide it explicitly.
- Fix: use versioned keys for “families” of cached data (dashboards, lists)
- Fix: if correctness matters, invalidate on writes that affect the view
Mistake 2 — Treating cache like a database
If writes fail when cache is down, the cache became part of your critical path.
- Fix: DB is source of truth; cache updates should be best-effort
- Fix: design read-through fallback that heals the cache naturally
Mistake 3 — One TTL to rule them all
A single TTL value is rarely correct across different data types and access patterns.
- Fix: set TTL per entity type (profile vs pricing vs permissions)
- Fix: add jitter to reduce synchronized expirations
Mistake 4 — Cache stampede on hot keys
Expiry-driven traffic spikes can overwhelm the DB even if average load is fine.
- Fix: lock/single-flight on miss; consider stale-while-revalidate for safe data
- Fix: pre-warm critical keys (carefully) after deploys
Mistake 5 — Unbounded key cardinality
Caching “every query string” (search, filters) can explode memory and trash hit rates.
- Fix: cache only stable, bounded keys (entity IDs, known aggregates)
- Fix: add caps: only cache top N popular queries, or cache per normalized bucket
Mistake 6 — No visibility into cache behavior
Without metrics, teams guess and over-cache. You want proof.
- Fix: track hit/miss rate, evictions, memory usage, and DB QPS
- Fix: log invalidations/version bumps with key prefixes (not raw sensitive keys)
Be deliberate with sensitive data in caches (PII, tokens, permissions). Use short TTLs, strict key scoping, and avoid caching secrets that could be leaked through logs or misconfiguration.
FAQ
When should I use read-through caching vs write-through caching?
Use read-through as the default when your service is read-heavy and you can tolerate bounded staleness. Reach for write-through (or write + invalidate) when users expect read-after-write correctness and the cached data maps cleanly to a single key (like “user by ID”).
What’s the safest way to avoid stale data disasters?
Be explicit about freshness and align it with your busting strategy. If staleness is acceptable, use TTL and keep it documented. If not, invalidate on writes or use versioned keys for derived views. Then add stampede protection so expiry doesn’t become an outage.
How do I choose the right TTL?
Choose TTL from business freshness requirements, not from performance intuition. Start with “how old can this data be without causing user harm?” Then add jitter (a small random offset) to spread expirations. If a strict freshness requirement exists, TTL alone is not enough—pair it with invalidation or versioned keys.
Is write + invalidate better than write-through?
Often, yes. Write + invalidate is simpler when cached values are derived (lists, aggregates) or when computing the correct cache value during a write is tricky. You update the DB, delete affected keys, and let read-through repopulate. Write-through is great when you’re caching an entity record that you already have after the write.
How do I prevent cache stampedes when many keys expire?
Use coordination: a short lock, single-flight, and TTL jitter. For safe data, consider stale-while-revalidate (serve slightly stale and refresh in the background). Also watch for deploys that cold-start many instances—pre-warm only the truly critical keys.
Should I cache “not found” responses?
Yes, carefully. Negative caching can drastically reduce DB load for missing IDs or repeated lookups, but keep TTL short and be sure it won’t hide newly created records longer than acceptable.
What’s a good key naming convention?
Use a predictable prefix + environment + entity + identifier:
app:prod:user:42, app:prod:user:42:dash:v7.
This makes it easier to monitor, reason about invalidations, and avoid collisions across services.
Cheatsheet
Scan this when you’re implementing caching patterns under time pressure.
Pick the pattern (fast)
- Read-heavy: read-through (cache-aside) + TTL
- Read-after-write matters: write-through or write + invalidate
- Derived views: versioned keys (plus TTL) or explicit invalidation
- High-risk correctness: cache only immutable data, or avoid caching
Production-ready defaults
- Key prefixing:
app:env:+ stable namespaces - Per-entity TTLs (document them)
- TTL jitter (10–60s typical) to reduce herd
- Stampede protection (lock/single-flight)
- Best-effort cache updates (don’t fail writes)
Busting decision checklist
| If you cache… | Prefer… | Because… |
|---|---|---|
| Entity by ID (user/product) | Invalidate-on-write or write-through | Clear mapping from writes to keys |
| Lists / dashboards | Versioned keys | Avoid enumerating all derived keys |
| Analytics / counts | TTL-only (or scheduled rebuild) | Often OK to be slightly stale |
| Expensive third-party API calls | Read-through + TTL + negative caching | Protects rate limits and reduces latency |
Implement read-through with a reasonable TTL, add jitter + a lock to prevent stampedes, and add targeted invalidation for the few entities where read-after-write correctness is required.
Wrap-up
Caching is one of the highest-leverage performance tools in software architecture—when you treat it as an optimization, not as a second source of truth. If you take only a few lessons from “Caching Patterns: Read-Through, Write-Through, and Busting,” take these:
- Start with read-through for safe, incremental wins.
- Use write-through or invalidate-on-write only where read-after-write matters.
- Pick a busting strategy (TTL, targeted invalidation, versioned keys) and make it observable.
- Prevent stampedes with jitter and coordination so expiry doesn’t become an outage.
Next actions (15 minutes)
- Write your key naming convention and TTL defaults in a short doc
- Pick one endpoint and implement read-through with jitter + lock
- Add a metric dashboard: hit rate, latency, DB QPS, evictions
- Identify the 1–2 flows that need immediate freshness and add invalidation
Quiz
Quick self-check (demo). This quiz is auto-generated for software / architecture / best.