Hiring BarSupport

Caching Fundamentals

Foundation38 min read6 diagrams

Why This Matters#

Caching is not a speed trick. It is a decision to keep a second copy of the truth and accept that, for some window, the copy will be wrong. Every cache design question is really three questions in disguise: how wrong is acceptable, for how long, and who finds out first when it's wrong? The candidate who answers "add Redis in front of the database" has answered none of them.

The mechanics are easy: look in the cache, fall back to the source on a miss, store what you found. What makes caching a Staff topic is that a cache changes the shape of your failure modes. Once a database is serving 5% of reads because the cache absorbs the other 95%, the database is no longer sized for your traffic — it is sized for your miss traffic. Lose the cache and the database sees 20× its normal load in one second. The cache has quietly become a load-bearing wall, and most teams only discover that during the outage.

The second reason caching dominates interviews is that it is where consistency becomes concrete. "Eventual consistency" is abstract; "the user changed their display name and still sees the old one for 5 minutes because the profile is cached with a 300-second TTL in three tiers" is a product decision someone has to sign. Staff candidates name that window, name who agreed to it, and name what happens for the 0.1% of keys where it matters (prices, permissions, inventory).

If you can walk an interviewer from a hit-ratio number ("95% means the DB sees 1/20th of reads") to a staleness budget ("profiles tolerate 60s, permissions tolerate 0") to a stampede defense ("singleflight plus jittered TTLs") to an ownership answer ("the cache is the product team's correctness problem and the platform team's availability problem"), you are demonstrating exactly the judgment the Staff bar is looking for.

The L5 → L6 → L7 Contrast#

BehaviorSenior (L5)Staff (L6)Principal (L7)
First move"Put Redis in front of the DB for the hot reads""What's the read:write ratio, the working-set size, and the staleness each field can tolerate? That picks the strategy per data class.""Do we need a cache, or a read model we own as a first-class store? Caches that can't be lost aren't caches."
Invalidation"Delete the key on write""Delete-on-write has a race with concurrent misses; I'll use versioned sets or leases, plus a TTL as the backstop."Sets an org rule: every cached entity has a declared staleness budget and an invalidation owner, reviewed like an API
Failure"If Redis is down, read from the DB""If Redis is down the DB gets 20×. I'll cap fallthrough with a concurrency limit and serve stale from L1."Treats a cold cache as a capacity event: game-days a full flush quarterly and budgets DB headroom for it
Hot keys"Redis is fast enough""One key at 200K reads/s pins one shard. L1 in-process cache with a 1–5s TTL, or replicate the key N ways."Puts hot-key detection into the shared client so 40 teams don't each discover it at 3 a.m.
Sizing"Give it 64 GB""Working set is ~30 GB; at 1.3× overhead and 70% fill target, 3 shards of 20 GB"Prices the cache against the DB replicas it replaces and kills caches with <80% hit ratio
Ownership"Platform runs Redis""Platform owns the cluster; the service owns keys, TTLs, and correctness."Decides which caching is centralized (clusters, client library) and which is never centralized (staleness policy)
Why "First move" separates levels

"Cache the hot reads" is a reasonable instinct — it is just one decision applied to data with very different correctness needs. A product page has a title (safe to cache for an hour), a price (safe for seconds, maybe), inventory (often unsafe), and a "you already own this" flag (must be fresh for this user). The Staff candidate partitions the data by staleness tolerance before choosing a strategy, and says it out loud: "I'll cache the catalog fields with a 10-minute TTL and event invalidation, keep price behind a 5-second TTL, and read inventory and entitlements from the source." One sentence, four decisions, all defensible.

Why "Failure" separates levels

"Fall back to the database" sounds like graceful degradation. In arithmetic, it's a cascading failure: at 100K reads/s with a 95% hit ratio, the DB normally serves 5K/s. Cache loss sends it 100K/s. The Staff answer treats the fallback path as a dangerous path — bounded concurrency, stale serving, load shedding — and the Principal answer treats the possibility of a cold cache as a capacity requirement the org has to budget and rehearse.

The 60-Second Version#

  • Hit ratio is leverage, not a vanity metric. Origin load = (1 − h) × QPS. Going from 90% to 99% hit ratio cuts origin load 10×, not 9%. That's why the last few points of hit ratio are worth fighting for.
  • Cache-aside is the default. Application reads cache, on miss reads the DB and populates. Write path updates the DB then deletes (not sets) the key. It's simple, failure-tolerant, and works with any store.
  • Invalidation is where correctness lives. TTL is the backstop, not the strategy. Delete-on-write has a known race; versioned keys, leases, or CDC-driven invalidation close it.
  • Stampedes kill origins. When a hot key expires, 10,000 concurrent misses all hit the DB. Request coalescing (singleflight), leases, and probabilistic early refresh cap that to ~1 origin call per key.
  • Hot keys break sharding. A single key at 100K+ reads/s lands on one shard no matter how many you add. Fix with an in-process L1 (1–5s TTL) or key replication.
  • Cache misses too. Negative caching (store "not found" for 30–60s) protects the DB from enumeration, typos, and abusive lookups.
  • TTL needs jitter. Set 10,000 keys at deploy with TTL=3600 and they expire in the same second. Add ±10–20% randomness.

How Caching Works#

The Vocabulary That Matters in an Interview#

TermMeaningWhy It Changes the Design
Hit / missFound in cache vs fetched from originMiss cost (DB query ~5–50ms) dominates tail latency
Hit ratio (h)hits ÷ (hits + misses)Origin sizing is (1 − h) × QPS; measure per key prefix, not globally
Working setKeys accessed in a window (e.g., last hour)If it fits in memory, h > 95% is realistic; if not, eviction decides
TTLTime until an entry is considered expiredUpper bound on staleness only if nothing else serves the stale value
EvictionRemoving entries when memory is full (LRU, LFU, TinyLFU)Policy matters when working set > memory; irrelevant when it fits
StalenessAge of the cached copy relative to the sourceThe product-facing number; must be named per data class
Fill / populateWriting a value into the cache after a missThe race-prone step; who fills, with what version
Cold / warmEmpty cache vs populatedCold start = origin sees full read load

Where Caches Live#

A request can hit five caches before touching your database. Each tier has a different latency, a different owner, and a different invalidation story.

TierTypical LatencyCapacityInvalidation ControlWho Owns It
Browser / mobile client0msMBs per userCache-Control, ETags — weak; you can't purge a phoneClient team + API contract
CDN edge5–30ms from userTBs across POPsPurge API, ~seconds to propagate globallyEdge/platform team
API gateway / reverse proxy<1ms addedGBsConfig-driven TTLs, limited purgeGateway team
In-process (L1)~100ns–1µs100MB–few GB per podPer-pod; no coordinated purge — keep TTLs shortService team
Distributed cache (L2: Redis, Memcached)0.2–1ms in-AZ10s GB–TBsPrecise delete per keyPlatform runs it; service owns keys
Database buffer poolµs (in RAM)DB memoryAutomatic, always consistentDBA / DB team

🎯 Staff Move: "Before I add a distributed cache, I'll check the two free ones: is the database's buffer pool already serving this from memory, and can the HTTP layer cache it with Cache-Control? A Redis tier is a new system with its own on-call; I want to know it's earning that."

The Hit-Ratio Math#

Two formulas carry most cache arguments:

effective_latency = h × cache_latency + (1 − h) × (cache_latency + origin_latency)
origin_qps        = (1 − h) × total_qps

Worked example — 50K reads/s, Redis at 0.5ms, Postgres at 8ms:

Hit RatioEffective Mean LatencyPostgres Reads/sPostgres Replicas Needed (at ~5K reads/s each)
0% (no cache)8.0ms50,00010
80%2.1ms10,0002
95%0.9ms2,5001 (+1 for HA)
99%0.58ms5001 (+1 for HA)

Read the last column backwards and you see the danger: the system at 99% has been sized for 500 reads/s. A cold cache at that moment is a 100× load event on that database.

🎯 Staff Insight: Mean latency improves modestly past 95%. Origin protection improves enormously. Argue for hit ratio in terms of origin load, not latency.

Core Strategies#

The four classic strategies differ on two axes: who talks to the database (the application or the cache) and when writes reach the database (synchronously or later).

StrategyRead PathWrite PathStalenessWrite DurabilityWho Pays
Cache-asideApp reads cache; on miss reads DB and fillsApp writes DB, deletes keyBounded by TTL + invalidation raceDB is truthService team writes fill/invalidate logic in every reader
Read-throughCache library loads from DB on missUsually paired with cache-aside invalidationSame as cache-asideDB is truthPlatform owns the loader abstraction; harder to debug misses
Write-throughReads hit cacheApp writes cache, cache (or app) writes DB synchronouslyLow — cache updated with the writeDB is truth, write latency adds bothWriters pay ~2× write latency; cache fills with never-read data
Write-behind (write-back)Reads hit cacheWrite to cache, async flush to DBLowest for readersCache is truth until flush — loss windowWhoever is paged when the cache dies with unflushed writes

Strategy 1: Cache-Aside (Lazy Loading)#

def get_user(user_id):
    key = f"user:v3:{user_id}"
    val = cache.get(key)
    if val is not None:
        return deserialize(val)            # hit
    row = db.query("SELECT ... WHERE id=%s", user_id)   # miss
    if row is None:
        cache.set(key, NOT_FOUND, ttl=60)  # negative cache
        return None
    cache.set(key, serialize(row), ttl=jitter(600))
    return row

def update_user(user_id, fields):
    db.update(user_id, fields)             # 1. source of truth first
    cache.delete(f"user:v3:{user_id}")     # 2. invalidate, don't set

When to use: almost always — read-heavy data, any database, when you want the cache to be optional. If Redis disappears, the code path still works (slowly).

Why delete instead of set on write: two concurrent writers can set in the opposite order from their DB commits, leaving the older value cached until TTL. A delete is idempotent and order-insensitive — the next reader fills from the database.

Failure mode: the stale-fill race (covered below) and the stampede on popular keys. Both have standard fixes; neither is a reason to avoid cache-aside.

Strategy 2: Read-Through#

cache = LoadingCache(
    loader=lambda key: db.fetch_product(parse_id(key)),
    ttl=jitter(300),
    coalesce=True,          # one loader call per key at a time
)

product = cache.get(f"product:{pid}")   # app never touches the DB directly

When to use: when you want one place to enforce coalescing, metrics, and negative caching — typically an in-process cache library (Caffeine, Guava-style loaders) or a platform-provided client. It centralizes the fill logic so 30 call sites can't each get it wrong.

Failure mode: the loader becomes a hidden dependency. A slow loader blocks callers inside what looks like a cache call; set a loader timeout shorter than the caller's deadline.

Strategy 3: Write-Through#

def update_price(sku, price):
    db.update_price(sku, price)                     # commit first
    cache.set(f"price:{sku}", price, ttl=jitter(3600))  # then update cache
    # if cache.set fails: delete or let TTL bound the damage; never retry forever

When to use: data that is read soon after it's written and must look fresh to readers — session state, user settings, a product price after an admin edit. Readers rarely miss.

Failure mode: partial failure between the two writes. The DB commit succeeds and the cache write fails (or vice versa), and you have divergence with no reader-triggered repair until TTL. Keep a TTL on every write-through key for this reason. Also: you cache everything written, including the 80% that is never read — memory cost for zero hits.

Strategy 4: Write-Behind (Write-Back)#

def record_view(video_id):
    cache.incr(f"views:{video_id}")          # fast, in memory
    dirty_set.add(video_id)

def flusher():                               # every 5 seconds
    for vid in dirty_set.drain():
        count = cache.getset(f"views:{vid}", 0)
        db.execute("UPDATE videos SET views = views + %s WHERE id=%s", count, vid)

When to use: high-rate, loss-tolerant aggregates — view counts, rate-limit counters, "last seen" timestamps — where 10,000 writes/s to one row would destroy the DB but a 5-second loss window is acceptable.

Failure mode: the cache is now the source of truth for the unflushed window. A node crash loses up to one flush interval of writes. If the business can't sign that sentence ("we may lose up to 5 seconds of view counts on a cache node failure"), write-behind is the wrong pattern — use a log (Kafka) and aggregate downstream instead.

🎯 Staff Move: "I'll only use write-behind if someone in product signs off on the loss window in writing. For anything involving money or entitlements, the database commits first and the cache follows."

Two Supporting Strategies#

StrategyWhat It DoesWhen It WinsWhat It Costs
Write-aroundWrites go to the DB only; cache fills on next readWrite-heavy data rarely read back (logs, audit rows)First read after write is always a miss
Refresh-aheadRefresh entries proactively before TTL expires if they're being readA few hundred very hot keys with expensive computation (home page modules, config)Wasted refreshes for keys that went cold; needs access tracking

Cache Invalidation: The Hard Sub-Problem#

Every cache strategy reduces to the same question: when the source changes, how does the copy find out? There are only four answers, and production systems usually combine two of them.

MechanismStaleness BoundComplexityFailure BehaviorWho Pays
TTL only= TTL (e.g., 60s)TrivialSelf-healing; always eventually correctUsers see stale data for up to TTL
Delete on write (app-driven)~ms, except during racesLowA missed delete = stale until TTLEvery writer must remember every key it affects
Versioned / generational keys~0 for readers that read the versionMediumOld versions orphaned until evictedMemory; one extra lookup for the version
CDC-driven invalidation (DB log → invalidator)~100ms–2s replication lagMedium–highLag spikes = staleness spikes; invalidator outage = TTL-bound stalenessPlatform team owns the invalidation pipeline

The Stale-Fill Race#

Delete-on-write cache-aside has a well-known race. It needs a slow reader and a concurrent writer — which, at 50K reads/s, happens every day on popular keys.

Diagram: The Stale-Fill Race

The reader read the old value before the write, but wrote it into the cache after the invalidation. The delete happened; it just happened too early. Three standard fixes:

FixHow It WorksCost
Leases (fill tokens)A miss returns a token; a delete invalidates outstanding tokens; a SET with a stale token is rejected. Facebook described this in its memcache paper.Needs cache-server support or a Lua script
Versioned set (compare-and-set)Store (version, value); SET only if the incoming version > cached version. Version = row updated_at or a monotonic counter.Every row needs a version column
Delayed second deleteWriter deletes, then deletes again ~500ms–1s later, covering in-flight fillsCheap, probabilistic — a reader slower than the delay still loses
-- Redis Lua: only fill if our version is newer than what's cached
local cur = redis.call('HGET', KEYS[1], 'v')
if (not cur) or tonumber(ARGV[1]) > tonumber(cur) then
  redis.call('HSET', KEYS[1], 'v', ARGV[1], 'data', ARGV[2])
  redis.call('EXPIRE', KEYS[1], ARGV[3])
  return 1
end
return 0

🎯 Staff Move: "Delete-on-write gets me most of the way, but there's a race between a slow miss-fill and a concurrent write. For profile data I'll accept it — the TTL bounds it to 10 minutes. For permissions I'll use versioned sets so a revoked role can never be re-cached."

Picking a TTL#

A TTL is a staleness budget with a number on it. Pick it from the product side, not the infrastructure side.

Data ClassTypical TTLInvalidationReasoning
Static assets (hashed filenames)1 yearNever — new hash = new URLContent-addressed; can't be stale
Catalog / article content5–60 minEvent-driven delete + TTL backstopEdits are rare; minutes of staleness is invisible
User profile, display names1–10 minDelete on writeUsers notice their own edits — pair with read-your-writes
Feeds, counts, "likes"10–60sTTL onlyApproximate by nature
Prices, availability shown on browse5–30sTTL + event deleteRe-validated at checkout from source
Permissions, entitlements, balances0–5s or no cacheVersioned set or skip cacheStale = security or money bug
"Not found" results30–60sDelete on createShorter than positive TTLs

Always add jitter. ttl = base × uniform(0.8, 1.2). Without it, a deploy or a bulk backfill that sets 2 million keys with TTL=3600 schedules a synchronized miss storm exactly one hour later.

Read-your-writes for the writer. The user who just edited their bio is the one person guaranteed to notice staleness. Delete the key on write and bypass the cache for that user's own reads for a few seconds (a short-lived "recently wrote" flag in their session), which is far cheaper than making the whole cache strongly consistent.

CDC-Driven Invalidation#

When many services write to the same tables, app-driven deletes fail by omission: the batch job, the admin tool, and the migration script all forget. Tail the database's change log instead.

Diagram: CDC-Driven Invalidation

The invalidator owns a row → keys mapping (users row 42 → user:v3:42, profile_card:42, team_members:{team_id}). That mapping is the real design artifact: if a derived key isn't in it, that key is stale until TTL. Monitor invalidation.lag_ms (p99 target < 2s) and keep TTLs on everything, so an invalidator outage degrades to "stale for one TTL" rather than "stale forever."

Visual Guide#

Cache-Aside Read and Write Paths#

Diagram: Cache-Aside Read and Write Paths

Choosing a Write Strategy#

Diagram: Choosing a Write Strategy

The Multi-Tier Topology#

Diagram: The Multi-Tier Topology

Each tier divides the load on the one below it. The rule for stacking tiers: TTLs shrink as you move toward the client-facing layers you can't purge (pods, browsers) and grow where you have precise invalidation (Redis with delete-on-write).

Implementation Patterns#

Stampede Protection#

A stampede (thundering herd, dog-pile) happens when a popular key expires or is evicted and every concurrent request misses at once. At 20K reads/s on one key with a 40ms recompute, ~800 requests pile onto the database in the first 40ms — for the same row.

TechniqueMechanismOrigin Calls per ExpiryTradeoff
Request coalescing (singleflight)Per process: first miss computes, others wait on its future1 per pod (100 pods → 100)Simple; no cross-pod coordination
Distributed lock / lease on fillSET lock:key NX PX 2000; losers wait or serve stale~1 globallyLock holder crash = 2s of waiting; needs stale fallback
Stale-while-revalidateStore value with a soft TTL and a hard TTL; past soft TTL, serve stale and refresh in background~1Readers see data up to hard − soft older
Probabilistic early expirationEach reader refreshes early with probability rising as expiry nears~1, spread over timeNo locks; requires storing compute time
// Singleflight + stale-while-revalidate, per process
func Get(key string) (Value, error) {
    e, ok := l2.Get(key)
    if ok && time.Now().Before(e.SoftExpiry) {
        return e.Val, nil                         // fresh hit
    }
    if ok {                                       // stale but usable
        go group.Do(key, func() (any, error) { return refresh(key) })
        return e.Val, nil
    }
    v, err, _ := group.Do(key, func() (any, error) { return refresh(key) })
    return v.(Value), err                         // hard miss: one caller per pod computes
}

Probabilistic early expiration (the "XFetch" approach) needs no coordination at all:

# delta = how long the recompute took last time; beta ≈ 1.0
def should_refresh(entry, now, beta=1.0):
    return now - entry.delta * beta * math.log(random.random()) >= entry.expiry

log(random()) is negative, so each reader pretends "now" is a little later than it is. Popular keys get refreshed by one lucky reader just before expiry; cold keys simply expire.

🎯 Staff Move: "Every hot read path gets singleflight in the client library by default. For the top few hundred keys I'll add stale-while-revalidate, so an expiry never reaches the database as a burst — the worst case is one refresh per key per pod."

Hot Keys#

Sharding spreads keys, not load. One celebrity profile, one viral product, or one global config key at 300K reads/s lands on a single Redis shard that tops out around 100–200K simple ops/s per core.

FixHowWhenCost
In-process L1Cache the hot key in each pod for 1–5sRead-only or tolerant of seconds of stalenessInvalidation can't reach pods quickly; keep TTL tiny
Key replicationWrite key#0..key#N-1; readers pick a random suffixHot key must stay in the shared tierN× write cost and memory; N deletes on invalidate
Read replicasRoute reads for hot shards to replicasRedis Cluster with replicasReplica lag; more nodes
Detect and promoteClient samples key frequency; keys over threshold auto-promoted to L1Unpredictable hot keys (viral content)Client-library complexity — build it once

Detection: cache.shard.ops_per_sec skew (one shard at 5× the median), client-side top-K sampling, or Redis --hotkeys with an LFU policy. Alert when any single key exceeds ~10% of a shard's capacity.

Negative Caching#

A miss for a key that doesn't exist in the source is the most expensive kind: it always hits the DB, and attackers can generate unlimited such keys.

NOT_FOUND = b"\x00nf"
val = cache.get(key)
if val == NOT_FOUND:
    return None                      # cached absence
  • TTL shorter than positive entries: 30–60s.
  • Delete the negative entry on create — otherwise a newly created user is "not found" for up to a minute.
  • For enumeration attacks (random IDs), negative caching alone just fills memory. Put a Bloom filter of existing IDs in front: ~1.2 bytes per key at a 1% false-positive rate, so 100M IDs fit in ~120MB.

Cache Warming#

ApproachUse WhenRisk
Replay top-N keys from access logs before shifting trafficNew cluster, region failover, planned flushStale log = warming the wrong keys
Shadow traffic to the new cluster for 10–30 minCluster migrationDoubles read load on source during warm-up
Ramp traffic 1% → 10% → 50% → 100%Any cold startSlow; needs a traffic-shifting control
Persisted snapshot (RDB/AOF) on restartSingle-node restartSnapshot is minutes old — combine with TTL

Failure Scenario: The Full-Flush Cascade#

A routine Redis cluster upgrade restarts nodes faster than replicas resync. The cache comes back empty.

t=0       Rolling restart finishes; cluster healthy but empty. Hit ratio 97% -> 0%.
t=+2s     Postgres read QPS 1.8K -> 60K. Connection pool (500) saturates.
t=+5s     Query latency 8ms -> 900ms. App threads block waiting for DB connections.
t=+15s    API p99 > 5s; load balancer health checks fail; pods marked unhealthy.
t=+30s    Fewer healthy pods -> more load per pod -> more failures (feedback loop).
t=+2min   Incident declared. Cache can't refill because fills depend on DB reads that time out.
t=+12min  Traffic shed to 20% at the gateway; cache warms; traffic ramped back over 15 min.
  • Detection: cache.hit_ratio drop > 20 points in 1 min; db.connections.active at pool max; db.read_qps > 3× baseline.
  • Mitigation: shed load at the edge, serve stale from L1/CDN, cap DB fallthrough concurrency per pod (e.g., 20), ramp traffic.
  • Prevention: upgrade one shard at a time with a hit-ratio gate; per-pod fallthrough limits as a client default; quarterly cold-cache game day.
  • Owner: platform team owns the upgrade procedure; service team owns the fallthrough limit and shed policy.

Operational Reality Matrix#

FailureDetection SignalBlast RadiusMitigationOwner
Cache cluster loss / flushcache.hit_ratio cliff, db.read_qps spikeEvery service reading through itFallthrough limits, edge shedding, warm rampPlatform (cluster) + service (fallback)
Stampede on hot keydb.query_rate{key_prefix} burst at expiryOne endpoint, then DB-wideSingleflight, SWR, jittered TTLService team
Hot key / shard saturationOne shard CPU > 80% while others < 30%All keys on that shardL1 promote, key replicationService (keys) + platform (detection)
Silent stalenessinvalidation.lag_ms p99, user reportsCorrectness on affected entity classRe-sync, fix missing key mapping, shorten TTLOwner of the invalidation path
Memory pressure / eviction stormcache.evictions_per_sec up, hit ratio drifting downGradual latency riseResize, fix oversized values, TTL auditPlatform
Negative-cache poisoningNewly created entity reported missingNew entities for ≤ negative TTLDelete negative entry on createService team

The Numbers in Context#

NumberValueWhy It Matters
L1 in-process lookup~100ns–1µs500× faster than a network hop; the hot-key escape hatch
Redis/Memcached GET, same AZ0.2–0.5ms p50, ~1–2ms p99The floor for any distributed cache read
Redis GET, cross-AZ+0.5–1ms and ~$0.01/GB each wayZone-local replicas pay for themselves at high QPS
Redis single-thread throughput~100–200K simple ops/s per shardThe hot-key ceiling; pipelining raises it
Memcached (multi-threaded)~1M+ ops/s per large nodeWhy huge pure-KV fleets still choose it
Postgres point read (indexed, in buffer pool)~0.5–2ms server, 5–10ms end-to-end with pool + networkThe miss cost you are avoiding
Per-key overhead in Redis~50–80 bytes + value100M small keys ≈ 8GB of pure overhead
Healthy hit ratio90–99% for entity caches; 70–85% for long-tail contentBelow ~80%, question whether the cache earns its cost
Target memory fill≤ 70–75% of maxmemoryHeadroom for fragmentation, replication buffers, forks
CDN purge propagation~1–5s for most large CDNs"Instant purge" still means seconds
Bloom filter cost~9.6 bits/key at 1% FPRCheap guard against negative-lookup floods
TTL jitter±10–20%Enough to de-synchronize bulk-set keys
Fallthrough concurrency per pod~10–50 concurrent DB fillsCaps origin load during a cold cache

Sizing a cache in one minute:

working_set   = 20M active users × 1.5KB serialized profile = 30GB
overhead      = × 1.3 (key + metadata + fragmentation)     = 39GB
fill target   = ÷ 0.7                                       = ~56GB
shards        = 56GB ÷ 25GB per shard                       ≈ 3 shards (+1 replica each)

How This Shows Up in Interviews#

Scenario 1: "Our product page takes 400ms. Add a cache."#

The trap is caching the whole page. Ask what makes up the 400ms: if it's 12 backend calls, cache the slow, shared, rarely-changing pieces (catalog, reviews summary) with event invalidation, and leave the user-specific and money-sensitive pieces (price for this user's tier, inventory, entitlement) uncached or on a 5s TTL. "I'll cache by data class, not by endpoint. The page cache would have to be keyed per user and would be stale on exactly the fields people complain about."

Scenario 2: "Design the caching layer for a social profile service — 200K reads/s, 2K writes/s." (Full Walkthrough)#

  1. Frame the data. Read:write is 100:1. Profiles change rarely, but the owner notices their own edits instantly. Some profiles (celebrities) are 1,000× hotter than median.
  2. Commit to the intent. "I'm optimizing for origin protection with bounded staleness — 60 seconds for other viewers, read-your-writes for the owner."
  3. Strategy. Cache-aside on Redis Cluster; key profile:v4:{user_id}; TTL 600s ±20%; delete on write; versioned set (using the row's version column) to close the stale-fill race.
  4. Size it. 50M active profiles × 2KB × 1.3 / 0.7 ≈ 185GB → 8 shards of ~25GB, each with a replica in another AZ.
  5. Origin math. Target h = 98% → 4K reads/s to Postgres; with replicas at ~5K reads/s each, 2 read replicas plus a third for headroom. Note that a cold cache would send 200K/s — so per-pod fallthrough is capped at 20 concurrent fills and the gateway can shed.
  6. Hot keys. Client library tracks per-key frequency; any key > 5K reads/s per pod is promoted to a 2s L1. Celebrity edits are visible to others within ~2s.
  7. Stampede. Singleflight per pod + stale-while-revalidate (soft TTL 600s, hard TTL 900s).
  8. Owner reads. After a write, set a 10s rw:{user_id} flag in the session; the owner's own reads bypass the cache while it's set.
  9. Multi-region. Each region has its own Redis; writes are single-region; CDC invalidator fans deletes to all regions; cross-region staleness ≈ replication lag (~1s) — acceptable.
  10. Operate. Alert on cache.hit_ratio < 95% for 5 min, invalidation.lag_ms p99 > 2000, shard.cpu skew > 3×. Platform owns the cluster; profile team owns key schema, TTL policy and invalidation mapping.

"The interesting part isn't Redis — it's that the system is now sized for 4K origin reads/s, so I've designed the cold-cache path as carefully as the hot path."

Scenario 3: "After a deploy, the DB fell over every hour on the hour."#

That's synchronized expiry: the deploy warmed (or the backfill wrote) millions of keys with an identical TTL. Fix with jitter, then audit for anything else that sets TTLs in bulk. A Staff candidate also asks why a one-hour synchronized miss could take down the DB at all — that's a missing fallthrough limit.

Scenario 4: "Should we cache permissions checks? They're 30% of DB load."#

Yes, carefully. Revocation is the case that matters: a fired employee must lose access within a bound the security team signs (commonly ≤ 60s, sometimes immediate). Cache grants with a short TTL (30–60s), key on (principal, resource, policy_version), bump policy_version on any revocation so all old entries become unreachable, and never negative-cache in a way that outlives a grant (deny-caching is safe; allow-caching is the risk). Owner: the authz team, not the callers.

Advanced Patterns#

Eviction Policies — When They Matter#

Eviction only matters when the working set exceeds memory. If it fits, buy memory and stop tuning.

PolicyGood AtBad At
LRURecency-heavy workloadsOne large scan evicts the hot set
LFU (with decay)Stable popularity (catalog, profiles)Slow to adapt to new hot items without decay
W-TinyLFU (Caffeine)Mixed workloads; admission filter rejects one-hit wondersIn-process only, typically
TTL-only (no eviction pressure)Working set fits in memoryRuns out of memory if TTLs are long and keys unbounded

Admission matters as much as eviction. Most keys in a long-tail workload are read once. A cache that admits every miss churns its hot set; an admission filter ("only cache on the second access") can raise hit ratio several points at the same memory.

Consistent Hashing and Resharding#

Distributed caches route keys to shards with consistent hashing or hash slots (Redis Cluster: 16,384 slots). Adding a shard to a modulo-hashed cache remaps ~(N−1)/N of keys — effectively a full flush. With consistent hashing or slots, only ~1/N moves. Never resize a modulo-hashed cache at peak.

Caching Computed Results, Not Rows#

Rows are cheap to fetch; joins, aggregates, and rendered fragments are not. The biggest wins usually come from caching the output of an expensive computation — a timeline page, a permission set, a search facet count — with invalidation tied to its inputs. The cost is dependency tracking: when any input changes, which outputs are dirty? Keep the dependency graph shallow (one level) or fall back to short TTLs.

Multi-Region Caches#

ApproachStalenessCostUse When
Independent cache per region, local DB replicaReplica lag (~100ms–1s) + invalidation lagCheapestDefault
Cross-region invalidation fan-out (CDC → each region)~1–2sModerateWrites in one region, reads everywhere
Replicated cache (cache is the cross-region transport)Varies; conflict-proneHighAlmost never — you've built a database

Beware the multi-region version of the stale-fill race: region B invalidates on the CDC event, but its local DB replica hasn't applied the write yet, so the next miss refills from the old replica. Fix: invalidate only after the local replica has applied the change (key the invalidator off the local replica's log, not the primary's), or use version-checked sets.

When a Cache Becomes a Database#

Warning signs: no TTLs anywhere, data that exists only in the cache, persistence turned on "just in case," and an outage plan that says "restore Redis from backup." At that point you have an under-engineered database. Either promote it honestly (durability, backups, replication guarantees, an owner who treats it as primary storage — see Redis) or restore the invariant that the cache can be flushed at any moment without data loss.

🎯 Staff Insight: "Can we flush this cache right now, at peak, without losing data or falling over?" is the single best audit question for any cache. Two yeses means it's a cache. Anything else means it's a liability with a cache's name.

The Principal Lens#

Why L7 Sees This Problem Differently#

At Staff level a cache is a component you design correctly. At Principal level caching is an org-wide pattern that silently changes everybody's capacity model. Forty services each add a cache to hit their latency targets; each one lets the shared database team shrink capacity; and the company now has forty undocumented load-bearing walls whose combined cold-start load nobody has ever computed. The L7 question is not "which strategy?" but "Which caches are allowed to be load-bearing, who budgets for their failure, and how do we stop each team from rediscovering stampedes?"

The Org-Level Fault Line#

A shared caching platform vs. caches owned by each service.

OptionWhat WorksWhat BreaksWho Pays
One shared multi-tenant cache clusterUtilization, one expert team, cheap to adoptNoisy neighbors; one team's hot key or flush hurts everyone; key collisionsEvery tenant during the bad afternoon
Per-service clusters on a managed platformIsolation, per-service sizing, independent upgradesMore clusters; under-utilized memoryPlatform headcount; finance (idle RAM)
Teams run their own RedisAutonomy30 inconsistent configs, no persistence/eviction standards, nobody on call at 3 a.m.Incident responders
Shared client library + per-service clustersCorrect defaults (singleflight, jitter, fallthrough limits, metrics) everywhereLibrary upgrades across 40 services; polyglot costPlatform team writes it once

The Principal default: per-service (or per-domain) clusters provisioned from one platform, plus one mandatory client library that bakes in coalescing, TTL jitter, fallthrough limits, negative caching, and standard metrics. Centralize the mechanisms; never centralize staleness policy — that belongs to the team that owns the data's meaning.

Cost Model#

Assumptions: managed in-memory cache ~$12–15 per GB-month of RAM with replica included; DB read replica ~$1.5–3K/month; loaded engineer ~$250K/yr; 3 AZs. Directional only.

ScaleCache FootprintCache $/monthDB Replicas AvoidedHeadcountNet
Startup (5K reads/s)1 small cluster, 8GB~$150–3000–1~0.1 FTEOften not worth it — buffer pool + HTTP caching first
Growth (100K reads/s)6–10 clusters, ~500GB~$6–8K10–15 replicas (~$25–40K)1 FTE on platformClearly positive; also buys latency
Enterprise (2M reads/s, multi-region)50+ clusters, ~15TB across 3 regions~$200–250KHundreds of replicas4–6 FTE platform + libraryPositive, but idle RAM and cross-AZ traffic become top-5 infra lines

The hidden lines at enterprise scale: cross-AZ traffic on cache reads (zone-aware routing cuts it), memory sized for peak but idle at night, and caches with < 80% hit ratio that cost more than the replicas they replace. An annual hit-ratio-per-dollar review typically finds 10–20% of clusters to shrink or kill.

The 3-Year Evolution Path#

Diagram: The 3-Year Evolution Path

One-Way Doors vs Two-Way Doors#

DecisionReversibilityCost to Reverse
Letting a cache become the only copy of some data (write-behind without a log)One-way once data is lostUnrecoverable writes; customer trust
Shrinking the DB because the cache absorbs readsOne-way-ishRe-provisioning under incident pressure takes hours
Promising "real-time" freshness in a public API or SLAOne-wayContract renegotiation
Cache key schema (user:v3:{id})Two-wayBump the version prefix; old keys age out
Redis vs Memcached vs managed offeringTwo-wayDual-read migration over a week
TTL values, eviction policy, shard count (with slots)Two-wayConfig change

🧭 Principal Move: "Teams can choose their cache engine and TTLs freely — those are cheap to change. What I'll gate is shrinking a database below its cold-cache load, because that's the decision that turns a cache outage into a company outage."

The Standard I'd Write#

RFC-CACHE-001: Caching on the Paved Road

Scope: Any service-side cache in front of a system of record.

MUST
  1. Use the platform cache client (coalescing, TTL jitter ±15%, fallthrough
     concurrency limit, negative-cache support, standard metrics).
  2. Declare a staleness budget per cached entity class in the service catalog,
     signed off by the owning product team.
  3. Set a TTL on every key. No unbounded keys.
  4. Survive a full cache flush at peak: origin capacity OR shed policy documented
     and tested at least twice a year.
  5. Never be the only copy of committed data (no write-behind without a durable log).
SHOULD
  6. Use versioned sets or leases for data with a staleness budget < 10s.
  7. Use CDC-driven invalidation when > 1 writer path touches the source table.
  8. Keep hit ratio >= 85%; clusters below 80% for a quarter are reviewed for removal.

Exceptions: Filed with the platform team; approved by platform lead and the
service's director; time-boxed to two quarters.

Success metrics: zero cache-flush-induced Sev1s per year; 100% of cached entities
with declared budgets; cache spend per 1K reads trending down YoY.

What I'd Tell the VP#

"Our caches make the product fast and let us run smaller databases — which also means that when a cache empties, the databases behind it can't cope. That's what happened in the March outage. I'm proposing three things: one standard cache library so every team gets proven protections by default, a written freshness promise for each kind of data so 'stale' stops being a surprise, and a twice-yearly drill where we deliberately empty a cache and confirm we stay up. It's about one quarter of platform work. It removes our most common cause of cascading outages and gives us a clear basis to trim the 15% of cache spend that isn't paying for itself."

Principal Interview Signals#

SignalWhat It Sounds Like
Treats the cold cache as a capacity requirement"The database is sized for miss traffic now. Who signed off on that?"
Centralizes mechanism, not policy"The library owns stampede protection; the product team owns how stale a price can be."
Prices hit ratio"This cluster costs $9K a month and saves two replicas at $5K. It goes."
Guards the one-way door"No write-behind without a log. Caches don't get to be the system of record by accident."
Rehearses failure"We flush one production cache a quarter, on purpose, during business hours."

Staff answers that L7 interviewers find insufficient:

  • "We'll add singleflight and jitter" — correct for one service, silent on the other 39 that will repeat the mistake.
  • "Invalidate via CDC" — without naming who owns the row → key mapping and what happens when it's incomplete.
  • "Size the DB for cache failure" — without pricing it or offering the cheaper alternative (shed policy + warm ramp).

In the Wild#

Facebook — Memcache at Scale#

Facebook's 2013 NSDI paper, Scaling Memcache at Facebook, describes look-aside caching across thousands of Memcached servers serving billions of requests per second. It documents leases to solve both stale sets and thundering herds, regional pools to separate rarely-accessed keys from hot ones, mcsqueal-style invalidation driven from the database commit log rather than application code, and "gutter" servers that take over for failed cache hosts so the database never sees the raw miss load.

Staff insight: The two hardest problems they wrote about — stale fills and herds — are exactly the two this page treats as core. Naming leases and log-driven invalidation in an interview signals you know where the bodies are buried.

Netflix — EVCache#

Netflix built EVCache on Memcached, replicating data across availability zones so each zone can serve reads locally and survive a zone loss. Clients write to every zone's copy and read from their own zone, falling back to another zone on a miss. Netflix has written about using it for personalization, session, and many other data sets across regions.

Staff insight: Zone-local reads aren't only about latency — they keep cross-AZ transfer off the bill and turn an AZ failure into a hit-ratio dip instead of an origin flood.

Twitter — Studying Real Cache Workloads#

Twitter's OSDI 2020 paper analyzed traces from 150+ production in-memory cache clusters and found that workloads vary enormously: many clusters are write-heavy, object sizes shift over time, and TTLs — not eviction — often determine what stays in memory. The paper argued that cache design should start from the workload, not from LRU assumptions.

Staff insight: "It's a cache, so it's read-heavy and LRU" is an assumption. Ask for the read:write ratio and TTL distribution before tuning anything.

Staff Calibration#

What Staff Engineers Say (That Seniors Don't)#

ConceptSenior (L5)Staff (L6)Principal (L7)
Hit ratio"We have a 95% hit ratio, great""95% means the DB sees 5K of 100K reads/s; a cold cache is a 20× event — here's the fallthrough cap""Every load-bearing cache has a documented cold-start plan; we game-day it"
Invalidation"Delete the key on update""Delete plus versioned set; CDC once we have more than one writer path""Staleness budgets are declared per entity and owned by product"
TTL"One hour""TTL from the product's staleness tolerance, ±20% jitter, shorter for negative entries""TTL policy is local; jitter and bounds are enforced in the shared client"
Hot keys"Add more shards""Sharding spreads keys, not load — L1 for 2s or replicate the key""Hot-key detection lives in the platform client with org-wide dashboards"
Write strategy"Write-through keeps it consistent""Write-through still has a partial-failure window; write-behind only with signed-off loss""No cache is the system of record without becoming a governed datastore"
Cost"Redis is cheap""Working set 40GB → 3 shards, ~$1K/month vs 4 replicas""We review hit-ratio-per-dollar annually and delete the losers"
Why "Invalidation" separates levels

A Senior who says "delete on write" is right 99.9% of the time — which at 200K reads/s is still hundreds of stale fills a day on hot keys. The Staff engineer knows the race and chooses whether to close it per data class (accept for names, close for permissions). The Principal engineer notices that the real failure isn't the race but omission — the batch job that writes the table without calling the cache — and moves invalidation off the application path entirely, with an owner for the mapping.

Why "Hit ratio" separates levels

Reporting a high hit ratio is reporting success. Staff engineers read the same number as a liability: the higher it is, the further the origin has drifted from being able to serve real traffic. Principal engineers turn that into an org practice — load-bearing caches are inventoried, their cold-start load is known, and someone has proven recently that the shed policy works.

Common Interview Traps#

  • "Add a cache" as the first sentence. Ask the read:write ratio, working-set size, and staleness tolerance first.
  • Setting on write instead of deleting. Concurrent writers reorder sets; deletes are order-insensitive.
  • No TTL "because we invalidate." Invalidation misses happen; the TTL is the bound on how long.
  • Ignoring the cold start. Say what the DB sees at 0% hit ratio and how you survive it.
  • Treating shards as a hot-key fix. One key lives on one shard.
  • Caching entitlements or balances like catalog data. Name the data classes you won't cache.
  • Write-behind for anything financial. If the cache dies, those writes are gone.
  • Global hit ratio as the only metric. A 97% global ratio can hide a 40% ratio on the endpoint that matters. Measure per key prefix.

Practice Drill#

Prompt: "A ticketing site shows event pages with seat-availability counts. On-sale moments send 500K reads/s to a single event page for about 10 minutes. The current design caches the page in Redis with a 30s TTL, and the database fell over at the last big on-sale. Fix it."

Staff Answer

First, separate the page's data by staleness tolerance: event details (title, venue, images) change rarely and can sit at the CDN for minutes; availability counts change thousands of times per second during an on-sale and are inherently approximate on a browse page; the actual seat hold must hit the source of truth and is not cached at all. The DB fell over because the whole page shared one key with a 30s TTL — every 30 seconds the key expired and ~500K reads/s × the recompute time (say 200ms) ≈ 100K requests fell through to the database at once. Fixes in order: (1) serve event details from the CDN with s-maxage=300, stale-while-revalidate=600 — that removes ~90% of origin traffic outright; (2) split availability into its own tiny key avail:{event_id} computed by a single background refresher every 1s from the inventory service, so readers never trigger a recompute (refresh-ahead, one writer); (3) put a 1s L1 in every API pod for avail:* — 300 pods × 1 read/s each = 300 Redis reads/s instead of 500K, which also kills the hot-key problem; (4) singleflight in the client as a backstop if the refresher dies, with stale-while-revalidate so readers see the last value rather than a miss; (5) the seat-hold path goes to the inventory system with its own admission control, and the UI copy says "~120 left" rather than an exact number. Metrics: cdn.hit_ratio (target > 95% on event pages), avail.refresh_age_ms (alert > 3000), db.read_qps during on-sale (target flat). Owners: ticketing team owns the staleness budget (product signs "availability on browse may be up to 2s old"); edge team owns CDN config; inventory team owns the hold path.

Why this is L6:

  • Diagnoses the outage as synchronized expiry on a hot key, with the arithmetic.
  • Splits the page by data class instead of tuning one TTL.
  • Moves recomputation off the read path (one refresher) so read volume no longer drives origin load.
  • Keeps the correctness-critical path (seat hold) out of the cache entirely and gets product sign-off on approximate counts.

What L7 adds:

  • Treats on-sales as a recurring capacity event: a pre-sale runbook that pre-warms the CDN and pins event keys, rehearsed before every major on-sale.
  • Puts the "background refresher + L1 + SWR" combination into the platform library as a named hot-entity mode, so the next team with a viral entity flips a flag instead of rebuilding it.
  • Prices it: CDN egress for the burst vs the DB replicas the old design would need to survive 100K fallthrough reads/s, and shows the CDN path is an order of magnitude cheaper.

Where This Appears#

  • Distributed Caching — the full case study: cluster topology, consistent hashing, replication, and cache-tier failure modes in depth
  • CDN & Edge Caching — HTTP caching, purge propagation, stale-while-revalidate at the edge
  • News Feed — precomputed timelines as a cache of an expensive computation
  • URL Shortener — extreme read:write ratios, hot links, negative caching for unknown codes
  • Flash Sales & Ticketing — hot entities, refresh-ahead, and what must never be cached
  • Leaderboard — write-behind counters and approximate freshness
  • Rate Limiting — counters in a cache that is (briefly) the source of truth

Related Foundations & Patterns: Consistent Hashing · Consistency Models · Back-of-Envelope Estimation · Scaling Reads · Degraded Mode Framework

Related Technologies: Redis · DynamoDB · API Gateways

  1. Loading the index…