Home/Learn/System Design/Caching Strategies

Caching Strategies

Beginner
Fundamentals

Caching stores frequently accessed data in a fast layer (memory) to reduce latency and database load. Strategies include cache-aside, read-through, write-through, write-behind, and refresh-ahead.

Overview

Caching is the single most impactful technique for improving system performance. A cache stores a subset of data in a faster storage layer (typically in-memory like Redis or Memcached) so that future requests can be served without hitting the slower origin (database, API, disk). The key challenges are cache invalidation (when does stale data get replaced?), cache eviction (what gets removed when the cache is full?), and cache consistency (how closely does cached data match the source of truth?). Different caching strategies — cache-aside (lazy loading), read-through, write-through, write-behind (write-back), and refresh-ahead — offer different trade-offs between consistency, performance, and complexity.

Cache-Aside (Lazy Loading)

The application checks the cache first. On a miss, it reads from the DB, stores the result in the cache, and returns it. On a write, the application updates the DB and invalidates (or updates) the cache entry. This is the most common pattern.

Java — cache-aside pattern with Redis
// Cache-aside with Redis + Spring Boot
@Service
public class ProductService {
    private final RedisTemplate<String, Product> redis;
    private final ProductRepository repo;

    public Product getProduct(String id) {
        String key = "product:" + id;

        // 1. Check cache
        Product cached = redis.opsForValue().get(key);
        if (cached != null) return cached;           // cache HIT

        // 2. Cache miss → read from DB
        Product product = repo.findById(id)
            .orElseThrow(() -> new NotFoundException(id));

        // 3. Populate cache with TTL
        redis.opsForValue().set(key, product, Duration.ofMinutes(30));
        return product;
    }

    public Product updateProduct(String id, ProductUpdateDTO dto) {
        Product updated = repo.save(/* ... */);
        redis.delete("product:" + id);   // invalidate cache
        return updated;
    }
}

Write-Through & Write-Behind

Write-through writes to cache and DB synchronously — ensures consistency but adds write latency. Write-behind (write-back) writes to cache immediately and asynchronously flushes to DB — faster writes but risk of data loss if cache crashes before flush.

Conceptual + Java — write strategies
// Write-through: cache + DB updated together
//  App → Cache → DB  (synchronous, consistent)

// Write-behind: cache updated, DB updated async
//  App → Cache → [async queue] → DB  (fast, risk of loss)

// Comparison
// ─────────────────────────────────────────────────────
// Strategy       | Read perf | Write perf | Consistency
// ─────────────────────────────────────────────────────
// Cache-aside    | Fast (hit)| Normal     | Eventual
// Read-through   | Fast (hit)| Normal     | Eventual
// Write-through  | Fast (hit)| Slower     | Strong
// Write-behind   | Fast (hit)| Fast       | Eventual (risk)
// Refresh-ahead  | Fast      | Normal     | Near real-time

// Spring @Cacheable (read-through abstraction)
@Cacheable(value = "products", key = "#id")
public Product getProduct(String id) {
    return repo.findById(id).orElseThrow();
}

@CacheEvict(value = "products", key = "#id")
public void updateProduct(String id, ProductDTO dto) {
    repo.save(/* ... */);
}

Eviction Policies & TTL

When the cache is full, eviction policies decide what to remove. LRU (Least Recently Used) is the most common. TTL (Time-To-Live) ensures entries expire after a fixed duration, bounding staleness.

Config + Java — eviction and TTL
// Common eviction policies:
// LRU  — evict least recently used (most popular)
// LFU  — evict least frequently used
// FIFO — evict oldest entry
// Random — evict a random entry

// Redis: set max memory + eviction policy
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru

// Caffeine (Java in-process cache) with LRU + TTL
Cache<String, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)                       // LRU eviction at 10K entries
    .expireAfterWrite(Duration.ofMinutes(15))  // TTL
    .recordStats()                             // hit/miss metrics
    .build();

// Multi-layer caching: L1 (in-process) → L2 (Redis) → DB
// L1 = Caffeine (ns latency), L2 = Redis (ms latency), DB (10s of ms)

Key Points to Remember

  • 1Cache-aside (lazy loading) is the most common strategy — app manages cache reads and invalidation.
  • 2Write-through ensures consistency but adds write latency; write-behind is faster but risks data loss.
  • 3LRU is the default eviction policy in most caches; always set a TTL to bound staleness.
  • 4Multi-layer caching (L1 in-process + L2 distributed) combines low latency with shared invalidation.
  • 5Cache invalidation is one of the hardest problems — prefer TTL-based expiry with event-driven invalidation.

Interview Questions

Sign in to ask Aria
1

What are the common caching strategies and when would you use each?

EasyInfosys
2

How do you handle cache invalidation in a microservices architecture?

MediumAmazon
3

What is the thundering herd problem and how do you solve it?

MediumGoogle
4

Compare Redis vs Memcached for a session cache.

MediumFlipkart
5

Design a multi-layer caching strategy for a product catalog with 10M items.

HardNetflix

Ask Aria about Caching Strategies

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…