← System Design Simulator

Cache Strategies

By Rahul Kumar · Senior Software Engineer · Updated · Category: System Design Primer · Unique Topics

Cache-aside / write-through / write-behind / refresh-ahead side-by-side.

This interactive explanation is built for system design interview prep: step through Cache Strategies, watch the internal state change, and connect the concept to real distributed-system trade-offs.

Overview

A cache only pays for itself if you know which writes populate it and which reads bypass it. The three strategies that cover 95% of real systems are cache-aside, write-through, and write-behind, and they differ only in who writes to the cache and when. Cache-aside is the lazy default: the application reads from the cache, falls back to the database on a miss, then repopulates the cache. Write-through synchronously writes to both cache and database on every write, trading write latency for guaranteed cache freshness. Write-behind writes to the cache only and flushes to the database asynchronously, trading durability for raw throughput. Each choice changes the failure modes of the system. Cache-aside can serve stale data after a DB write; write-through doubles your write path but keeps reads consistent; write-behind loses the last few seconds of writes if the cache node dies. Picking the wrong strategy for your workload is one of the top five ways to break production.

Cache Strategies — Interactive Simulator

Runs fully client-side in your browser; no sign-up. Or open full screen →

Launch the interactive Cache Strategies widget — step through the algorithm or protocol and observe the internal state updating in real time.

How it works

Cache-aside reads check the cache first. On a miss, the app loads from the database, inserts into the cache with a TTL, and returns. On a write, the app updates the database and explicitly invalidates (not updates) the cache key; this avoids races where two concurrent writers write to the cache in the wrong order. The TTL is a cheap defense against stale data when invalidation is missed. Write-through intercepts writes at the cache layer. Every write goes cache-then-DB (or a wrapper that does both); the call does not return until both succeed. Reads hit cache and never miss for populated keys, so read latency is near-constant but write latency is the sum of cache and DB latency. This is the preferred strategy for read-heavy workloads with small working sets, such as user-profile services. Write-behind treats the cache as the source of truth for a short window. The cache writes are acknowledged immediately, and a background flusher drains a queue of dirty entries into the database in batches. Throughput is cache-write-speed, not DB-write-speed, which is why systems like MySQL's InnoDB buffer pool and Redis AOF-lazy-flush use it under the hood. The cost is data loss risk: a cache crash loses anything not yet flushed. Production systems usually pair it with replicated caches or a WAL on the cache node.

Implementation

CacheAsideService: app-managed miss and invalidation
public class CacheAsideService {
    private final Cache<String, User> cache;
    private final UserRepository db;
    private final Duration ttl = Duration.ofMinutes(10);

    public CacheAsideService(Cache<String, User> cache, UserRepository db) {
        this.cache = cache; this.db = db;
    }

    public User get(String id) {
        User u = cache.get(id);
        if (u != null) return u;
        u = db.findById(id);
        if (u != null) cache.put(id, u, ttl);
        return u;
    }

    public void update(String id, User u) {
        db.save(u);
        cache.invalidate(id); // invalidate, not update, to avoid write-write races
    }
}
WriteThroughService and WriteBehindService
public class WriteThroughService {
    private final Cache<String, User> cache;
    private final UserRepository db;
    public WriteThroughService(Cache<String, User> c, UserRepository d) { this.cache = c; this.db = d; }

    public void put(String id, User u) {
        db.save(u);           // block until durable
        cache.put(id, u);     // then publish to readers
    }
    public User get(String id) { return cache.get(id); }
}

public class WriteBehindService {
    private final Cache<String, User> cache;
    private final BlockingQueue<User> flushQueue = new LinkedBlockingQueue<>(100_000);
    private final UserRepository db;

    public WriteBehindService(Cache<String, User> c, UserRepository d, ScheduledExecutorService exec) {
        this.cache = c; this.db = d;
        exec.scheduleWithFixedDelay(this::drain, 100, 100, TimeUnit.MILLISECONDS);
    }

    public void put(String id, User u) {
        cache.put(id, u);
        flushQueue.offer(u);  // acked immediately
    }

    private void drain() {
        List<User> batch = new ArrayList<>(256);
        flushQueue.drainTo(batch, 256);
        if (!batch.isEmpty()) db.batchSave(batch);
    }
}

Complexity

Key design decisions & trade-offs

Common pitfalls

Interview follow-ups

Recommended reading

Related