top of page

Cache Hot Reads Without Breaking Data

  • Shawn West
  • Jul 30
  • 3 min read

Updated: Aug 6

Performance Engineering · Part 4

Caching is the fastest way to speed up a read-heavy system — and the fastest way to serve stale or wrong data if you get it slightly off. The right cache turns a repeated expensive lookup into a near-instant one; the wrong one creates bugs that only appear under load. This walks through caching hot reads safely: picking a candidate, choosing a TTL, and handling the two hard parts — invalidation and cache stampedes.

When the same data is fetched repeatedly, caching it eliminates the work. This tutorial walks through adding cache to a real endpoint.

Step 1: Find a Cache Candidate (5 min)

Good candidates:

  • Same query, same parameters, called repeatedly

  • Data doesn't change every second

  • Stale data is acceptable for some seconds

Bad candidates:

  • Per-user, per-request data (low repeat)

  • Data that must be exact (account balance)

  • Things that change every read

Step 2: Measure Without Cache (5 min)

Baseline:

@app.route('/api/product/<int:product_id>')
def get_product(product_id):
    start = time.time()
    product = db.get_product(product_id)
    print(f"Query: {time.time() - start:.3f}s")
    return jsonify(product)

Say it's 50ms. With cache: should be sub-ms.

Step 3: Add a Simple Cache (10 min)

import redis
cache = redis.Redis()

@app.route('/api/product/<int:product_id>')
def get_product(product_id):
    key = f"product:{product_id}"
    
    cached = cache.get(key)
    if cached:
        return cached  # Pre-serialized JSON
    
    product = db.get_product(product_id)
    json_str = json.dumps(product)
    
    cache.setex(key, 300, json_str)  # TTL 5 minutes
    return json_str

5-minute TTL. Cache for hot products; stale for 5 min max.

Step 4: Measure With Cache (3 min)

# Load test
for i in range(1000):
    requests.get(f'http://localhost/api/product/{i % 10}')  # 10 products, repeated

First fetch per product: 50ms. After: < 1ms. Massive win.

Step 5: Pick the Right TTL (5 min)

Too short: cache useless. Too long: stale data.

Match the use case:

  • Real-time-ish (prices, status): 30-60 seconds

  • Profile data: 5-15 minutes

  • Catalog data: 1+ hour

  • Reference data (countries, currencies): days

When in doubt: 5 minutes.

Step 6: Handle Invalidation (10 min)

When data changes:

def update_product(product_id, data):
    db.update_product(product_id, data)
    cache.delete(f"product:{product_id}")

Cache cleared; next request fetches fresh.

For data with many keys (e.g., search results), use patterns:

def update_category(category_id, data):
    db.update_category(category_id, data)
    
    # Invalidate everything related
    for key in cache.scan_iter(f"category:{category_id}:*"):
        cache.delete(key)

Step 7: Stampede Protection (10 min)

When TTL expires under load, many requests hit DB simultaneously:

def get_product(product_id):
    key = f"product:{product_id}"
    cached = cache.get(key)
    if cached:
        return cached
    
    # Get a lock
    lock_key = f"lock:{key}"
    if cache.set(lock_key, "1", nx=True, ex=10):
        try:
            product = db.get_product(product_id)
            cache.setex(key, 300, json.dumps(product))
            return product
        finally:
            cache.delete(lock_key)
    else:
        # Wait briefly; retry
        time.sleep(0.05)
        return get_product(product_id)

Only one request rebuilds; others wait.

Step 8: Cache Negative Lookups (5 min)

If the DB returns "not found," cache that too:

product = db.get_product(product_id)
if not product:
    cache.setex(key, 60, "null")  # Short TTL
    return None

cache.setex(key, 300, json.dumps(product))

Prevents repeated DB hits for non-existent items.

Step 9: Multi-Level Cache (5 min)

For very hot data, cache in memory too:

from functools import lru_cache

@lru_cache(maxsize=1000)
def get_product_local(product_id):
    return get_product(product_id)

L1 (in-process) — microseconds. L2 (Redis) — milliseconds. L3 (DB) — milliseconds+. Each layer adds complexity but reduces tail latency.

Step 10: Monitor Cache Health (5 min)

Track:

  • Hit rate per cache type

  • TTL adequacy (how often expire vs invalidate)

  • Memory usage (Redis eviction?)

  • Errors (cache unavailable?)

Falls back to DB if cache fails:

try:
    cached = cache.get(key)
    if cached:
        return cached
except RedisError:
    # Cache unavailable; fall through to DB
    pass

product = db.get_product(product_id)

Cache outage shouldn't break the app.

What You Just Did

You added caching with proper TTL, invalidation, stampede protection, negative caching, fallback. Hot reads now near-instant.

Common Failure Modes

Caching everything. Memory blows up.

Forever TTL. Stale forever.

No invalidation. Stale data; user complaints.

Cache stampede. TTL expiry pile-up; DB hammered.

Hard dependency on cache. Cache down = app down.

Continue the Performance Engineering path

Part of the Performance Engineering learning path.

bottom of page