Caching Strategies Explained: Cache-Aside, Write-Through and Invalidation

A practical guide to caching — where to cache, cache-aside vs read-through vs write-through vs write-behind, TTLs, invalidation, stampedes, eviction policies and Redis patterns.

By Danyal Ahmed - Data Scientist · · 5 min read

Caching is the most effective performance tool in system design. A well-placed cache can cut response times from hundreds of milliseconds to single digits and reduce database load dramatically. It's also a reliable source of bugs: stale data, inconsistent views, and outages when the cache fails. As the old joke goes, cache invalidation is one of the two hard problems in computer science.

Key takeaways

  • Cache data that's read often, changes rarely, and is expensive to compute or fetch.
  • Cache-aside is the most common and flexible pattern; write-through keeps the cache fresh on writes.
  • Every cached value needs an invalidation strategy: TTLs, explicit deletes on writes, or event-driven invalidation.
  • Protect against cache stampedes with request coalescing, locks or early refresh.
  • The system must survive cache failure. A cache is an optimization, not a source of truth.

Where caching happens

textBrowser cache → CDN/edge cache → Reverse proxy cache → App in-memory cache
             → Distributed cache (Redis/Memcached) → Database buffer cache
Layer Caches Notes
Browser Static assets, API responses Controlled by Cache-Control, ETags
CDN Assets, cacheable pages/API responses Closest to users (what is a CDN)
Application memory Hot config, small lookups Fastest; per-instance, so not shared
Distributed cache Query results, sessions, computed views Shared across app instances
Database Pages and indexes in memory Automatic; size your RAM

Cache as close to the user as correctness allows.

Core caching patterns

Cache-aside (lazy loading)

The application checks the cache, falls back to the database on a miss, then populates the cache:

pythondef get_product(product_id: int) -> dict:
    key = f"product:{product_id}"
    cached = redis.get(key)
    if cached:
        return json.loads(cached)
    product = db.fetch_product(product_id)            # cache miss
    redis.set(key, json.dumps(product), ex=300)       # TTL: 5 minutes
    return product

def update_product(product_id: int, changes: dict):
    db.update_product(product_id, changes)
    redis.delete(f"product:{product_id}")             # invalidate after write
  • ✅ Only caches what's actually requested; resilient if the cache is down (it falls back to the DB).
  • ⚠️ The first request is slow; there's a window for stale data; the app owns the cache logic.

Delete, don't update, on writes. Setting the new value from application code risks race conditions where an older value overwrites a newer one. Deleting forces the next read to fetch fresh data.

Read-through

The cache itself loads from the database on a miss (via a loader configured in the caching layer). Similar behavior to cache-aside, but the logic is centralized.

Write-through

Writes go to the cache and the database together (synchronously), so the cache is always populated with fresh data.

  • ✅ Reads after writes are fresh; good for read-heavy data that must be current.
  • ⚠️ Higher write latency; caches data that may never be read.

Write-behind (write-back)

Writes go to the cache, which asynchronously flushes to the database later.

  • ✅ Very fast writes; batches database load.
  • ⚠️ Risk of data loss if the cache fails before flushing; complex consistency. Use only when you can tolerate that risk (such as counters and analytics).

Refresh-ahead

Proactively refresh popular entries before they expire, to avoid misses on hot keys.

Invalidation strategies

  1. TTL (time-to-live): entries expire automatically. Simple and a safety net for everything. Choose TTLs by how stale data can acceptably be.
  2. Explicit invalidation on write: delete affected keys when data changes. Precise, but you must know every key that depends on the data.
  3. Event-driven invalidation: publish change events (or use change data capture) and have a consumer delete or refresh keys. Decoupled and reliable for complex systems.
  4. Versioned keys: include a version in the key (product:42:v17). Bumping the version makes old entries unreachable, which is great for bulk invalidation.

Combine them: explicit or event-driven invalidation for freshness, plus a TTL so mistakes eventually heal.

Cache stampede (thundering herd)

When a hot key expires, thousands of concurrent requests miss at once and all hit the database, sometimes taking it down.

Mitigations:

  • Request coalescing / single-flight: only one request recomputes; the others wait for its result.
  • Locks: take a short-lived lock (such as Redis SET key NX EX) before recomputing.
  • Stale-while-revalidate: serve the slightly stale value while one worker refreshes it in the background.
  • TTL jitter: randomize expirations so keys don't all expire together.
  • Probabilistic early refresh: recompute slightly before expiry with increasing probability.

Eviction policies

When memory is full, the cache evicts entries:

  • LRU (least recently used): a good general default.
  • LFU (least frequently used): better when popularity is stable.
  • TTL-based: evict expired or soonest-expiring keys first.

Monitor hit rate, evictions and memory. A low hit rate means you're caching the wrong things, TTLs are too short, or the cache is too small.

What not to cache (or cache carefully)

  • Highly personalized data with little reuse: low hit rates.
  • Rapidly changing data where staleness is unacceptable (account balances, inventory at checkout). Read from the source of truth for critical decisions.
  • Sensitive data: be careful with cache keys that include user data, and never cache authenticated responses in shared caches (CDNs) without correct Cache-Control: private headers.
  • Large objects that crowd out many small hot ones.

Designing for cache failure

If Redis goes down, what happens? A system that can't serve traffic without its cache has turned an optimization into a single point of failure. Plan for it:

  • Fall back to the database with rate limits or circuit breakers so it isn't overwhelmed.
  • Use replication and failover for the cache cluster.
  • Warm caches gradually after restarts.
  • Load-test cold-cache scenarios (load testing guide).

Measuring impact

  • Cache hit ratio by key pattern.
  • Latency with and without the cache (p50, p99).
  • Database load reduction.
  • Staleness incidents: bugs caused by stale data are a real cost to weigh against performance gains.

Caching is usually step two in scaling, right after fixing slow queries (how to scale a web application).

FAQ

What's the difference between Redis and Memcached?

Both are fast in-memory caches. Memcached is a simple, multi-threaded key-value cache. Redis offers rich data structures (lists, sets, sorted sets, streams), persistence options, replication and scripting, making it useful beyond caching for rate limiting, queues and leaderboards.

How long should my cache TTL be?

As long as the business can tolerate stale data. Seconds for rapidly changing data, minutes to hours for catalogs, and days for static reference data. Use explicit invalidation for freshness and TTLs as a safety net.

Should I cache database query results or objects?

Caching domain objects or computed views (a product with prices and availability) is usually easier to invalidate than caching raw query results with many parameter combinations. Cache at the level where invalidation is clearest.

Can caching cause bugs?

Yes: stale reads, inconsistent data across instances, leaking private data through shared caches, and stampedes. Clear ownership of invalidation logic, TTL safety nets and correct HTTP cache headers prevent most of them.