Redis is fast. Caches in general are fast. That’s rarely the interesting part — any cache is fast when it hits. The interesting part is what happens on miss, on failure, on invalidation, and under concurrent access. This article covers the patterns that make caching reliable instead of just fast.

The three common patterns

Cache-aside. Application checks cache first; on miss, reads DB, writes to cache. Standard, simple, most flexible.

Write-through. Every write to DB also writes to cache atomically. Cache stays fresh; complexity higher.

Write-behind. Writes go to cache; async worker persists to DB. Fast writes, more risk of data loss.

For 90% of systems, cache-aside is right. Write-through for read-after-write scenarios where staleness is unacceptable. Write-behind rarely worth the complexity.

Cache-aside in Java

public Product getProduct(String id) {
    // Try cache
    String cached = redis.get("product:" + id);
    if (cached != null) {
        return json.readValue(cached, Product.class);
    }

    // Miss — fetch from DB
    Product product = productRepo.findById(id).orElseThrow();

    // Populate cache
    redis.setex("product:" + id, 300, json.writeValueAsString(product)); // 5 min TTL

    return product;
}

Invalidation on write:

public void updateProduct(Product p) {
    productRepo.save(p);
    redis.del("product:" + p.id());  // remove from cache
}

Why short TTLs beat clever invalidation

Cache invalidation is famously hard. The trick: mostly don’t. A 30-second TTL means at worst you see 30-second-stale data. For most use cases, that’s fine. You skip an entire class of invalidation bugs.

When you need freshness — explicit invalidation on write — do that too. But default to short TTL + explicit invalidation on major updates. Don’t build a sophisticated invalidation system until short TTLs prove insufficient.

Thundering herd (aka cache stampede)

Hot key expires. A thousand concurrent requests all miss simultaneously. All query the DB. DB melts.

Mitigations:

Request coalescing. First miss-request locks the key; other concurrent misses wait for it. Caffeine’s AsyncLoadingCache does this in-process; for Redis, implement with a short lock key.

public Product getProduct(String id) {
    String cached = redis.get("product:" + id);
    if (cached != null) return parse(cached);

    // Try to acquire lock
    boolean locked = redis.setnx("lock:product:" + id, "1", 3);
    if (!locked) {
        // Someone else is loading; wait briefly and retry
        Thread.sleep(50);
        return getProduct(id);
    }

    try {
        Product p = productRepo.findById(id).orElseThrow();
        redis.setex("product:" + id, 300, serialize(p));
        return p;
    } finally {
        redis.del("lock:product:" + id);
    }
}

Probabilistic early recomputation. Before expiry, occasionally recompute. Spreads load, avoids all-miss-at-once.

Jitter on TTLs. Instead of fixed 300s, use 270s-330s randomly. Expiries spread across a window.

Two-level caches

In-process cache (Caffeine) in front of Redis:

Request
  → Caffeine (sub-ms)
    → Redis (1-3 ms)
      → Postgres (10-50 ms)

Typical hit rates: Caffeine 70-90% on hot data, Redis catches another 8-15%, DB sees 1-5%.

Use Caffeine’s async loading cache to combine with Redis:

AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofSeconds(30))
    .buildAsync((key, exec) -> fetchFromRedisThenDb(key, exec));

Cache stampede on cold start

Service restart. Cache is empty. All requests miss. DB gets hammered.

Mitigations:

  • Warm-up script. Pre-populate hot keys on startup.
  • Gradual ramp. Load balancer routes traffic gradually as instances warm up.
  • Permit limiter. Only N concurrent DB lookups at any moment; rest wait.

For services with huge query cost on miss, warm-up is essential. For services with cheap miss-path, slowly-increasing traffic is enough.

Negative caching

Miss query returns null. Do you cache the null?

Yes, with a short TTL (5-30s). Otherwise, repeated requests for a nonexistent ID bypass the cache entirely and hit the DB. For attacks (enumerate IDs) this is dangerous.

if (product == null) {
    redis.setex("product:" + id, 10, "NULL");  // cache negative
    return null;
}

Consumers must handle the sentinel; null itself doesn’t round-trip through most serializers.

Cache keys that don’t collide

  • Namespace by entity type: product:123, user:456
  • Include version: product:v2:123 — bump version to invalidate all at once
  • Stable format: no whitespace, use colons as delimiter, predictable structure

Keys are strings; structure saves you later when you need to find / scan / delete.

Memory management

Redis with maxmemory + eviction policy:

maxmemory 4gb
maxmemory-policy allkeys-lru

allkeys-lru evicts least-recently-used keys when full. Good default for cache use. For use cases where keys have explicit TTLs and you never want to evict before TTL, use volatile-lru.

Know what your cache does when full. Reject writes? Evict something? Answers differ per policy.

Monitoring

Metrics that matter:

  • Hit rate per cache-tier — low hit rates mean caching isn’t working
  • Eviction rate — high means cache is too small
  • p99 latency of cache operations — rising means Redis is overloaded
  • Memory usage trend — hitting maxmemory = eviction party

Alert on sudden hit-rate drops — usually indicates a code change that changed key patterns or a Redis issue.

Things I’ve seen go wrong

Unbounded cached values. Huge JSON blobs in Redis. One key = 10MB. Network becomes bottleneck.

No TTL. Keys accumulate forever; memory fills; eviction randomizes behavior.

Cache as database. Storing primary data in Redis without persistence. Redis restart = data gone.

Same cache for sensitive data. Cached tokens with no separation from public product data. Security review never touched caching.

Ignoring Redis is a SPOF. Redis outage = total miss = DB can’t handle it. Have a plan for “Redis is slow” and “Redis is gone”.

Closing note

Redis as a cache is simple in the abstract and full of gotchas in practice. Treat caching as a pattern to design, not a library to drop in. Pick an approach (cache-aside is the sensible default), protect against stampedes, use TTLs with jitter, monitor hit rates, and make sure the system degrades gracefully when the cache is down. Do those five things and cache becomes a quiet performance multiplier instead of a source of incidents.