Medium15 minDistributed Systems
UpdatedAug 2, 2026
Edit

Caching Strategies

CONCEPTS:Distributed Caching

Question Variations

  • "Explain the difference between a 'Cache-Aside' strategy and a 'Write-Through' strategy."
  • "What is the 'Thundering Herd' problem in caching, and how can you mitigate it?"
  • "How do you decide on the appropriate TTL (Time To Live) for a cached resource?"
  • "What are the common strategies for cache invalidation?"

Why This Is Asked

Caching is one of the most effective ways to improve system performance. Interviewers want to know if you understand the trade-offs between different strategies and how you handle the risk of stale data.

Key Concepts

  • Cache Hit vs. Cache Miss: Understanding the performance impact.
  • Invalidation: How to handle data updates.
  • TTL (Time To Live): Using expiration to manage staleness.
  • Thundering Herd Problem: What happens when a popular cache key expires simultaneously for many users.

Question Variations

  • “Explain the difference between a ‘Cache-Aside’ strategy and a ‘Write-Through’ strategy.”
  • “What is the ‘Thundering Herd’ problem in caching, and how can you mitigate it?”
  • “How do you decide on the appropriate TTL (Time To Live) for a cached resource?”
  • “What are the common strategies for cache invalidation?”

Answers by Technology

+ Add Variant

Expected Answer (Java)

In a Java service, cache-aside reads a shared cache first and loads the repository only on a miss. The returned value is then written with a bounded TTL. A successful write to the source of truth should invalidate or version the affected cache entry; never assume the cache is the authoritative copy. Set client timeouts, pool connections, and use metrics to ensure cache failures fall back safely rather than cascading through application threads.

For very hot keys, coordinate regeneration so many request threads do not all query the database after expiry. Framework annotations can reduce boilerplate, but the team still owns key design, invalidation, serialization compatibility, TTL selection, and failure semantics.

Why It Matters

An incorrectly scoped cache key can leak data between tenants, while synchronous cache stalls can exhaust servlet or worker threads. Treating caching as a resilience design keeps it from becoming a single point of failure.

Example Code

public User getUser(String id) {
    String key = "user:" + id;
    String cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return objectMapper.readValue(cached, User.class);
    }

    User user = repository.findById(id).orElseThrow();
    redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(user),
        Duration.ofMinutes(5));
    return user;
}

Common Mistakes

  • Using a key without tenant or authorization context: A cached response can be returned to the wrong user or tenant.
  • Assuming an annotation solves invalidation automatically: The update path still needs a correct eviction or versioning policy.

Follow-up Questions

  • What should cache keys include for multi-tenant data? (Answer: The tenant identity and any response-shaping authorization or locale dimensions.)
  • Why use a TTL even with explicit eviction? (Answer: It bounds the impact of missed invalidation events.)

Expected Answer (Python)

Python services commonly implement cache-aside with a Redis or Memcached client: deserialize a cached value when present, query the authoritative repository on a miss, then populate a short-lived entry. Treat deserialization failures and cache timeouts as misses, and delete or version the key after a committed source update. The design must make the permissible stale window explicit.

The cache client should use connection pooling and timeouts. Async services should use an async-compatible client rather than blocking the event loop. Protect hot keys with request coalescing, stale serving, or a distributed lock where the extra coordination is justified.

Why It Matters

Blocking calls or unbounded timeouts can turn a cache issue into stalled Python worker capacity. Clear fallback behavior keeps the database authoritative while the cache reduces normal read load.

Example Code

async def get_user(user_id: str) -> User:
    key = f"user:{user_id}"
    cached = await redis.get(key)
    if cached is not None:
        return User.model_validate_json(cached)

    user = await repository.find_user(user_id)
    await redis.set(key, user.model_dump_json(), ex=300)
    return user

Common Mistakes

  • Using a synchronous client inside an async request path: It blocks the event loop and reduces concurrent throughput.
  • Caching an unbounded object without a TTL: Memory pressure and stale values accumulate.

Follow-up Questions

  • How should a Python async service handle cache timeouts? (Answer: Bound the operation and fall back to the source if the endpoint can safely do so.)
  • Why pool cache connections? (Answer: Creating connections per request adds latency and can exhaust server connection limits.)

Expected Answer (Go)

In Go services, cache-aside is a practical default: read the cache, load the authoritative store on a miss, and populate the cache with a bounded TTL. The cache must remain optional—timeouts, serialization errors, and misses should fall back to the source when the endpoint can tolerate it. On a successful update, invalidate the affected key or write a versioned replacement; TTL is a backstop, not immediate consistency.

Use context deadlines on cache operations and emit metrics for hit rate, error rate, latency, and origin fallbacks. For hot keys, add request coalescing or a distributed coordination strategy so a miss does not produce a burst of identical database queries.

Why It Matters

Without deadlines and fallback behavior, a cache outage becomes an application outage. Without invalidation or stampede control, the cache can serve stale data or overload the database exactly when it is under pressure.

Example Code

func GetUser(ctx context.Context, id string) (User, error) {
    key := "user:" + id
    if value, err := rdb.Get(ctx, key).Result(); err == nil {
        var user User
        if json.Unmarshal([]byte(value), &user) == nil {
            return user, nil
        }
    }

    user, err := repository.FindUser(ctx, id)
    if err != nil {
        return User{}, err
    }
    encoded, _ := json.Marshal(user)
    _ = rdb.Set(ctx, key, encoded, 5*time.Minute).Err()
    return user, nil
}

Common Mistakes

  • Failing the request on every cache error: A cache should usually degrade to the source rather than becoming a hard dependency.
  • Omitting a context deadline: Slow cache connections can consume all request capacity.

Follow-up Questions

  • How would you coalesce concurrent misses in Go? (Answer: Use a per-key singleflight group, and coordinate across instances when needed.)
  • Why ignore a cache-set failure after a successful source read? (Answer: The source result is still valid; caching is an optimization.)

Expected Answer

The choice of caching strategy depends on the read/write patterns of your application.

  1. Cache-Aside is the most versatile. The app is responsible for reading from and writing to the cache. It’s resilient to cache failures—if the cache is down, the app just goes to the DB.
  2. Read-Through/Write-Through shifts the responsibility to the cache provider/library. This keeps the application code cleaner but makes the system more dependent on the cache’s availability.
  3. Write-Behind is great for write-heavy workloads (like logging or real-time analytics) where you can afford to lose a small amount of data if the cache crashes before it syncs to the DB.

For invalidation, you usually use a TTL (Time To Live) for a simple approach, or explicit invalidation (purging the key when the DB record is updated) for high consistency.

Why It Matters

Proper caching can make an application feel instantaneous and reduce database costs by 90%. However, poor caching leads to “stale data” bugs that are notoriously difficult to debug, where a user sees old information even after an update.

Example Code

interface User { id: string; name: string; }

async function getUser(id: string): Promise<User | null> {
  const cachedUser = await cache.get(`user:${id}`);
  if (cachedUser) return JSON.parse(cachedUser) as User;

  const user = await db.users.find(id);

  if (user) {
    await cache.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
  }

  return user;
}

Common Mistakes

  • Caching everything by default: Caching data that changes constantly (like a real-time stock price) can actually slow the system down due to the overhead of constant cache writes and invalidations.
  • Infinite TTL: Forgetting to set an expiration time, leading to the cache filling up with data that is never used again.
  • Cache Penetration: Not caching “null” results. If a user queries for a non-existent ID repeatedly, the app will hit the DB every time. (Solution: Cache the “not found” result for a short time).

Follow-up Questions

  • What is a “Cache Avalanche”? (Answer: When many cache keys expire at the same time, or the cache service goes down, causing all traffic to hit the DB at once. Solution: Use random TTL jitters).
  • What is the “Thundering Herd” (or Cache Stampede)? (Answer: When a very popular key expires and many parallel requests try to regenerate the cache entry simultaneously. Solution: Use locking or pre-warming).
  • Difference between Redis and Memcached? (Answer: Redis supports complex data structures (lists, sets, hashes), persistence, and pub/sub. Memcached is a simpler, multi-threaded, purely in-memory key-value store).

References