Week 6: Caching Strategies: Cache-Aside, Write-Through & Write-Behind

Week 5 pushed the data layer past a single machine with replication and sharding — but both are still ultimately reads and writes against disk-backed storage, which Week 1's latency table showed is orders of magnitude slower than memory. This week adds a caching layer in front of the database: cache-aside, write-through and write-behind are the three strategies for keeping a fast in-memory copy of data in sync with the source of truth, each trading off latency, consistency and complexity differently. Week 7 builds directly on this with eviction and invalidation, and write-behind's async flush previews the message-queue patterns Week 8 covers in depth.

Module 5 of 24 Week 6 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain where a cache sits in the request path and why it only helps when access patterns have locality
  • Compare cache-aside, write-through and write-behind and pick the right one for a given read/write pattern
  • Decide when a local in-process cache is enough versus when a distributed cache is required

1. Where a Cache Fits in the Request Path

A cache is a small, fast store that holds a copy of data whose source of truth lives somewhere slower — typically the database. Recall Week 1's latency numbers: a main memory reference is roughly 100 ns, while a disk seek is 2–10 ms — five orders of magnitude slower. A cache exploits that gap by keeping frequently accessed data in memory, so most requests never touch the disk-backed database at all.

a cache in the request path
[Client] --> [Load Balancer] --> [App Server] --> [Cache] --(miss)--> [Database]
                                                       |
                                                  (hit) returns immediately,
                                                  database never touched

cache hit ratio = hits / (hits + misses)

Caching only pays off when access has locality — a small subset of data accounts for most requests, often approximated by the 80/20 rule (roughly 20% of keys drive 80% of traffic). A perfectly uniform access pattern, where every key is equally likely to be requested, gets little benefit from caching no matter how big the cache is, because the cache can't hold everything and misses stay high regardless of size. The cache hit ratio — the fraction of requests served from cache — is the single most important metric for judging whether a cache is earning its complexity.

Justify a cache with a hit ratio argument, not a reflex

"I'd add a cache here" is a weak answer on its own. "This is a read-heavy, skewed access pattern — a small number of products account for most page views — so a cache should get a high hit ratio and meaningfully cut database load" is the same design decision with the reasoning an interviewer is actually listening for.

2. Cache-Aside (Lazy Loading)

Cache-aside — also called lazy loading — is the most common caching pattern in interview answers and in production systems alike. The application, not the cache, is responsible for keeping the cache populated: on a read, the app checks the cache first; on a hit, it returns the cached value; on a miss, it reads from the database, writes that value into the cache, and then returns it. Writes typically go straight to the database, with the corresponding cache entry either invalidated (deleted) or left to expire.

cache-aside — pseudocode
function getUser(id):
    value = cache.get(id)
    if value is not null:
        return value          # cache hit
    value = db.query("SELECT * FROM users WHERE id = ?", id)
    cache.set(id, value, ttl=300)   # populate on miss
    return value

function updateUser(id, newData):
    db.update("users", id, newData)
    cache.delete(id)           # invalidate, don't update in place

Cache-aside's strengths make it the default choice: only data that's actually requested ever gets cached (no wasted memory on cold data), and if the cache goes down entirely, the system degrades to hitting the database directly on every request — slower, but still correct and available. Its weaknesses are the first request for any given key always misses (a "cold" cache, or cold start after a deploy or eviction), and there's a brief window after a write where a concurrent read can still see the old cached value until invalidation completes — a race condition Week 7 examines in more depth alongside the related "thundering herd" problem, where many concurrent misses on the same key all hit the database at once.

Delete on write, don't update in place

The pseudocode above deletes the cache entry on write rather than recomputing and writing the new value into the cache. Deleting is simpler and avoids a subtle race where two concurrent writes could leave a stale value in the cache if their cache-writes land out of order — Week 7 covers exactly why "delete, don't update" is the safer default for cache invalidation.

3. Write-Through & Write-Behind (Write-Back)

Cache-aside treats the cache as populated only by reads. The other two strategies involve the cache directly in the write path. In write-through caching, every write goes to the cache and the database together, synchronously, before the write is acknowledged to the client — the cache is always consistent with the database because it's updated as part of every write, not just reads. Write-behind (or write-back) caching writes to the cache immediately and acknowledges the client right away, then asynchronously flushes the write to the database sometime later, often batched.

write-through vs. write-behind — pseudocode
# Write-through: write is not "done" until both succeed
function updateUser_writeThrough(id, newData):
    cache.set(id, newData)
    db.update("users", id, newData)   # synchronous, on critical path
    return success

# Write-behind: write returns fast, DB write happens later
function updateUser_writeBehind(id, newData):
    cache.set(id, newData)
    writeQueue.enqueue({id, newData})  # async flush, batched
    return success   # client sees this before the DB is touched

# A background worker later drains writeQueue and applies
# batched updates to the database.

The three strategies trade off differently along the same axes:

comparing the three strategies
Cache-aside:
  Write latency:  fastest (cache untouched on write)
  Read-after-write risk: brief staleness until invalidation lands
  Data-loss risk: none (DB is always the write target)

Write-through:
  Write latency:  slower (pays cache + DB synchronously)
  Read-after-write: always consistent, cache is never stale
  Data-loss risk: none (DB write is synchronous)

Write-behind:
  Write latency:  fastest (only cache write is on critical path)
  Read-after-write: consistent from cache, but DB briefly lags
  Data-loss risk: real -- a cache crash before flush loses writes

Write-behind's low write latency and high write throughput make it attractive for workloads like view counters, activity logs or analytics events, where losing a small amount of recent data on a crash is an acceptable cost — an explicit choice to favor availability and throughput over strict durability, the same kind of tradeoff Week 9's CAP discussion formalizes. Because the flush is asynchronous and typically batched, a durable queue between the cache and the database (Week 8) is what makes write-behind safe enough for production rather than a silent data-loss risk.

Write-through is rare as a standalone strategy

In practice, write-through is usually paired with cache-aside-style reads rather than used alone: it guarantees the cache is never stale, but every write pays the full synchronous cost even for data nobody ever reads again. Naming that cost — and that write-through only makes sense when the write-then-read pattern is tight and consistency matters more than write latency — is the kind of nuance interviewers reward.

4. Local vs. Distributed Caching

A local (in-process) cache lives in the same process as the application — a plain in-memory map, or a library like Caffeine or Guava Cache. It's the fastest option because there's no network hop at all, but it has real limits: its capacity is bounded by that one instance's memory, its contents vanish on restart or deploy, and — critically in a horizontally scaled service — every app server has its own independent copy, so the same key can be cached with different values (or cached on one server and not another) across the fleet.

A distributed cache — Redis or Memcached, typically run as its own cluster — is shared across every app server. All instances see the same cached value for a given key, it survives any single app server restarting, and it can itself be sharded (Week 5's sharding concepts apply here too) to scale beyond one cache node's memory. The cost is a network round trip on every access — still vastly faster than a database round trip, but slower than an in-process lookup.

local vs. distributed — where each lives
Local (in-process), per app server:
[App Server 1: local cache]   [App Server 2: local cache]
      (independent, can disagree on the same key)

Distributed, shared cluster:
[App Server 1] --\
[App Server 2] ---> [Redis / Memcached cluster] --> [Database]
[App Server 3] --/
      (one shared view of every cached key)

Hybrid (common in high-throughput systems):
[App Server] --> L1: local cache (nanoseconds)
                 --> L2: distributed cache (sub-millisecond)
                 --> L3: database (milliseconds)

The deciding question is whether the application can tolerate different servers seeing different cached values for the same key. A read-mostly, slowly changing reference dataset (feature flags, a small config table) is often fine as a local cache — the inconsistency window between instances is small and low-stakes. Anything where correctness depends on every server agreeing — session data, inventory counts, rate-limit counters (Week 11) — needs a distributed cache as the single shared source of cached truth.

"Local cache" is a red flag for anything shared across servers

If a design calls for consistent behavior across a horizontally scaled fleet — and Week 3 established that stateless, horizontally scaled services are the default — a local cache silently reintroduces per-instance state. Naming that tradeoff explicitly, and reaching for a distributed cache when consistency across instances matters, is a detail that separates a strong caching answer from a superficial one.

5. Hands-on Exercise

Hands-on

Design the caching layer for an e-commerce product page

The page shows product details (title, description, images — rarely change) and a live inventory count (changes constantly as items sell). Traffic is heavily skewed toward a small number of popular products.

Requirements:

  1. Choose a caching strategy (cache-aside, write-through, or write-behind) for the product details data, and justify it against how often that data changes and how it's read.
  2. Choose a caching strategy for the live inventory count, and justify it separately — it's fine, and expected, for this to differ from your Step 1 answer.
  3. Sketch the request path for a product page view as a diagram, showing where each cache lookup happens.
  4. Assuming 10,000 product-page requests/second and an estimated 90% cache hit ratio for product details, calculate how many requests/second actually reach the database.
  5. Name one specific risk introduced by your inventory-count strategy from Step 2, and describe one mitigation.
Hint

The product details and the inventory count are the same page but very different data-change patterns — that's the whole point of this exercise. Selling out an item is exactly the kind of write where staleness has a real business cost (overselling), which should push your Step 2 answer toward a strategy that keeps the cache closer to the database's current value than cache-aside alone guarantees.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does caching provide little benefit for a perfectly uniform access pattern, even with a large cache?

Caching works by keeping the small subset of frequently accessed data in fast memory. If every key is equally likely to be requested, there's no "hot" subset for the cache to concentrate on — the cache still has finite size relative to the full dataset, so a large fraction of requests miss no matter how the cache is sized, and the hit ratio (the metric that determines whether the cache is worth its complexity) stays low.

Q2

What specific risk does write-behind (write-back) caching introduce that cache-aside and write-through don't have, and why is that risk sometimes acceptable?

Write-behind acknowledges a write once it's in the cache, then flushes to the database asynchronously later — if the cache crashes before that flush completes, the write is lost, since the database was never actually updated. This is acceptable for workloads like counters or activity logs where a small amount of recent data loss is a tolerable cost in exchange for much lower write latency and higher write throughput than a synchronous write-through or database write would allow.

Q3

In cache-aside, why is deleting a cache entry on write generally preferred over updating it in place?

Updating the cache in place on every write requires recomputing and writing the new value, and if two writes to the same key happen concurrently, their cache updates can land out of order and leave a stale value cached indefinitely. Deleting the entry instead is simpler and self-correcting: the next read just falls through to the database and repopulates the cache with the current value, removing the ordering race entirely.

Q4

Why is a local in-process cache a risky default choice for data that must be consistent across a horizontally scaled fleet of app servers?

Each app server's local cache is an independent copy with no coordination between instances, so the same key can hold different values on different servers, or be cached on one and missing on another. For data where correctness depends on every server seeing the same value — session state, inventory counts, rate-limit counters — that per-instance inconsistency is a real correctness bug, not just a performance detail, which is why such data belongs in a shared distributed cache instead.