Back to Scenarios & Design Questions
Q2Question 2 of 67

What are the main caching patterns and when do you use each?

Key Concepts
  • Cache-Aside puts load-on-miss logic in the application: check cache, on miss load from DB, populate cache, return.
  • Read-Through delegates loading to the cache itself via a configured loader, simplifying application code.
  • Write-Through writes to cache and database synchronously for consistency at the cost of write latency.
  • Write-Behind writes to cache first and flushes to the database asynchronously, risking data loss if the cache fails before flush.
  • Caffeine is an in-process cache with nanosecond lookups but is not shared across instances.
  • Redis is a distributed cache shared by all instances but adds a network hop per access.
  • Multi-level caching layers Caffeine L1 in front of Redis L2 for the best of both.

Definition

Cache-Aside puts the load-on-miss logic in the application, Read-Through delegates it to the cache, Write-Through writes synchronously for consistency, Write-Behind writes asynchronously for speed, and you move from Caffeine to Redis the moment multiple instances must agree.
  • Cache-Aside (Lazy Loading) – the application checks the cache first; on a miss it loads from the database, writes to the cache with a TTL, and returns. The app owns the logic.
  • Read-Through – the cache owns loading: the app only asks the cache, and the cache fetches from the database on a miss through a configured loader.
  • Write-Through – every write goes through the cache, which synchronously writes to both cache and database before returning.
  • Write-Behind (Write-Back) – the write updates the cache immediately and the database is updated asynchronously, giving fast writes but risking data loss on cache failure.
  • Caffeine – in-process JVM cache with nanosecond lookups, scope limited to one instance.
  • Redis – distributed cache shared across all instances, adds a network hop but ensures consistency.

At National Bank of India, the authoritative ledger is in the vault (the database) and the teller keeps a notepad of recently looked-up balances (the cache). Cache-Aside is the teller's own habit: "check my notepad; not there? I'll run to the vault." Read-Through is having an assistant who quietly fetches from the vault when the notepad is empty. Write-Through is updating both the notepad and the vault at the same time. Write-Behind is scribbling the change on the notepad instantly and walking it to the vault later. When ten tellers each have a private notepad, they disagree, so the bank installs one shared whiteboard (Redis) instead.

Understanding the Concepts

What is Cache-Aside?

Cache-Aside is the most common pattern and the default for most services. The application controls everything: on a read, check the cache; on a miss, load from the database, write to the cache with a TTL, and return.

JAVA
package com.bank.cache;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

public class CacheAsideExample {

    private final Map<String, BigDecimal> cache = new HashMap<>();
    private final Map<String, BigDecimal> database = Map.of(
            "SAV2001", new BigDecimal("75000.00"),
            "SAV3002", new BigDecimal("120000.00"));

    public BigDecimal getBalance(String accountId) {
        BigDecimal cached = cache.get(accountId);
        if (cached != null) {
            System.out.println("Cache HIT for " + accountId);
            return cached;
        }
        System.out.println("Cache MISS for " + accountId + ", loading from DB");
        BigDecimal fromDb = database.get(accountId);
        if (fromDb != null) {
            cache.put(accountId, fromDb); // populate cache
        }
        return fromDb;
    }

    public static void main(String[] args) {
        CacheAsideExample svc = new CacheAsideExample();
        svc.getBalance("SAV2001"); // miss
        svc.getBalance("SAV2001"); // hit
        svc.getBalance("SAV3002"); // miss
    }
}
TEXT
Cache MISS for SAV2001, loading from DB
Cache HIT for SAV2001
Cache MISS for SAV3002, loading from DB

What is Read-Through?

Read-Through moves the load-on-miss responsibility into the cache itself. The application only asks the cache, and the cache invokes a configured loader on a miss.

JAVA
package com.bank.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import java.math.BigDecimal;
import java.time.Duration;

public class ReadThroughExample {

    // Caffeine LoadingCache is a Read-Through cache
    private final LoadingCache<String, BigDecimal> cache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(5))
            .build(accountId -> loadFromDatabase(accountId)); // loader

    private BigDecimal loadFromDatabase(String accountId) {
        System.out.println("Loader: fetching " + accountId + " from DB");
        return new BigDecimal("75000.00"); // simulated
    }

    public BigDecimal getBalance(String accountId) {
        return cache.get(accountId); // miss triggers loader transparently
    }

    public static void main(String[] args) {
        ReadThroughExample svc = new ReadThroughExample();
        System.out.println("Balance: " + svc.getBalance("SAV2001"));
        System.out.println("Balance: " + svc.getBalance("SAV2001")); // hit, no loader call
    }
}
TEXT
Loader: fetching SAV2001 from DB
Balance: 75000.00
Balance: 75000.00

What are Write-Through and Write-Behind?

Write-Through writes to both the cache and the database synchronously. Write-Behind writes to the cache and flushes to the database asynchronously.

JAVA
package com.bank.cache;

import java.math.BigDecimal;
import java.util.concurrent.ConcurrentHashMap;

public class WritePatternsExample {

    private final ConcurrentHashMap<String, BigDecimal> cache = new ConcurrentHashMap<>();

    // Write-Through: sync write to both
    public void writeThrough(String accountId, BigDecimal balance) {
        cache.put(accountId, balance);             // cache update
        System.out.println("DB write (sync): " + accountId + " = " + balance);
        // Both updated before returning. Consistent but slower.
    }

    // Write-Behind: cache now, DB later
    public void writeBehind(String accountId, BigDecimal balance) {
        cache.put(accountId, balance);             // cache update (instant)
        System.out.println("Queued for DB flush: " + accountId + " = " + balance);
        // Background thread flushes to DB later. Fast but risks data loss.
    }

    public static void main(String[] args) {
        WritePatternsExample svc = new WritePatternsExample();
        svc.writeThrough("SAV2001", new BigDecimal("80000.00"));
        svc.writeBehind("SAV3002", new BigDecimal("95000.00"));
    }
}
TEXT
DB write (sync): SAV2001 = 80000.00
Queued for DB flush: SAV3002 = 95000.00

How do you compare the caching patterns?

| Pattern | Who Loads? | Write Path | Consistency | Durability Risk | Best For |
|---|---|---|---|---|---|
| Cache-Aside | App code | App invalidates cache | Eventual (TTL) | None (DB is truth) | Read-heavy, most services |
| Read-Through | Cache (loader) | Separate | Eventual (TTL) | None | Centralized loading logic |
| Write-Through | Cache | Sync to both | Strong | None | Reads must see latest write |
| Write-Behind | Cache | Async to DB | Eventual | Yes (cache failure) | High-write, counters, metrics |

When do you evolve from Caffeine to Redis?

Caffeine is in-process: a lookup is a method call with no network hop, nanosecond speed. But each instance has its own private cache. When you run multiple instances, they drift: a write on one instance does not invalidate the stale copy on another.

JAVA
package com.bank.cache;

public class CaffeineVsRedis {

    public static void main(String[] args) {
        System.out.println("=== Single Instance ===");
        System.out.println("Caffeine: nanosecond lookups, perfect for one instance");

        System.out.println();
        System.out.println("=== Multiple Instances ===");
        System.out.println("Instance-1 Caffeine: SAV2001 = 75000 (stale!)");
        System.out.println("Instance-2 Caffeine: SAV2001 = 80000 (updated)");
        System.out.println("Problem: instance-1 never heard about the update");

        System.out.println();
        System.out.println("=== Fix: Shared Redis ===");
        System.out.println("Redis: SAV2001 = 80000 (shared, all instances see it)");
        System.out.println("Cost: network hop per access (~0.5ms vs ~50ns)");
    }
}
TEXT
=== Single Instance ===
Caffeine: nanosecond lookups, perfect for one instance

=== Multiple Instances ===
Instance-1 Caffeine: SAV2001 = 75000 (stale!)
Instance-2 Caffeine: SAV2001 = 80000 (updated)
Problem: instance-1 never heard about the update

=== Fix: Shared Redis ===
Redis: SAV2001 = 80000 (shared, all instances see it)
Cost: network hop per access (~0.5ms vs ~50ns)

What is multi-level caching (L1 Caffeine + L2 Redis)?

The best of both worlds: a small, short-lived Caffeine L1 in each instance sits in front of a shared Redis L2. The hottest keys are served at nanosecond speed from L1 while L2 keeps instances roughly consistent.

JAVA
package com.bank.cache;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

public class MultiLevelCache {

    private final Map<String, BigDecimal> l1 = new HashMap<>();  // Caffeine (per-instance)
    private final Map<String, BigDecimal> l2 = new HashMap<>();  // Redis (shared)
    private final Map<String, BigDecimal> db = Map.of("SAV2001", new BigDecimal("75000.00"));

    public BigDecimal get(String accountId) {
        BigDecimal val = l1.get(accountId);
        if (val != null) { System.out.println("L1 HIT"); return val; }

        val = l2.get(accountId);
        if (val != null) { System.out.println("L2 HIT"); l1.put(accountId, val); return val; }

        System.out.println("DB LOAD");
        val = db.get(accountId);
        l2.put(accountId, val);
        l1.put(accountId, val);
        return val;
    }

    public static void main(String[] args) {
        MultiLevelCache cache = new MultiLevelCache();
        cache.get("SAV2001"); // DB LOAD
        cache.get("SAV2001"); // L1 HIT
    }
}
TEXT
DB LOAD
L1 HIT

What are common mistakes?

The most dangerous mistake is caching the authoritative balance used to authorize a debit. A cache with a TTL is eventually consistent by definition. You cache a displayed balance but you always read the authoritative balance from the database when authorizing a debit.

JAVA
package com.bank.cache;

import java.math.BigDecimal;

public class CachingMistakes {

    // WRONG: using cached balance for debit authorization
    static void wrongDebit(BigDecimal cachedBalance, BigDecimal amount) {
        if (cachedBalance.compareTo(amount) >= 0) {
            System.out.println("WRONG: approved debit based on stale cached balance");
            // Cache might be 2 seconds old, actual balance could be lower
        }
    }

    // CORRECT: read authoritative balance from DB for debit
    static void correctDebit(BigDecimal dbBalance, BigDecimal amount) {
        if (dbBalance.compareTo(amount) >= 0) {
            System.out.println("CORRECT: approved debit based on DB (source of truth)");
        }
    }
}

What are common interview follow-up questions?

  • How do you handle cache invalidation on writes in Cache-Aside?
  • When is Write-Behind acceptable for financial data?
  • How do you prevent a cache stampede when a hot key expires?
  • What is the consistency trade-off of the L1 TTL in multi-level caching?
  • How does Redis publish/subscribe help with cross-instance invalidation?

Visual Overview

DIAGRAM
  Cache-Aside Flow
  ────────────────
  Client → App
            │
            ├─ cache.get(key)
            │    HIT? → return cached value
            │    MISS? ──► db.load(key)
            │              │
            │              ▼
            │         cache.put(key, val, TTL)
            │              │
            └──────────────┘
                return val


  Multi-Level Cache (L1 + L2)
  ───────────────────────────
  Client → App
            │
            ├─ L1 (Caffeine, in-process, ~50ns)
            │    HIT? → return
            │    MISS?
            ├─ L2 (Redis, distributed, ~0.5ms)
            │    HIT? → populate L1 → return
            │    MISS?
            ├─ Database (source of truth, ~5ms)
            │    → populate L2 → populate L1 → return
            │
  Hottest keys served from L1 (nanoseconds).
  L2 keeps instances consistent.
  DB sees only a trickle of misses.

Key Takeaway

The main caching patterns differ in who talks to the database and when. Cache-Aside is the most common: the application checks the cache, loads from the database on a miss, and populates the cache. Read-Through moves loading into the cache via a configured loader. Write-Through writes synchronously to both cache and database for consistency. Write-Behind writes the cache first and flushes to the database asynchronously for speed, but risks data loss.

The evolution from Caffeine to Redis is driven by moving from one application instance to many. Caffeine is blazing fast but per-instance, so multiple instances drift apart after writes. Redis is shared across all instances but adds a network hop. Multi-level caching gives the best of both: a short-lived Caffeine L1 serves the hottest keys at nanosecond speed while Redis L2 keeps instances consistent.

The senior judgment is that a cache with a TTL is eventually consistent by definition. You cache a displayed balance for fast reads, but you always read the authoritative balance from the database when authorizing a debit, because stale data in a financial decision is far worse than a cache miss.

Remember one sentence: Cache-Aside for most services, evolve from Caffeine to Redis when multiple instances must agree, and never cache the authoritative balance used to approve a debit.

---

Share
Use ← → arrow keys to navigate