System Design NotesRohit’s interview study guide
Chapter 03

Caching

Where copies of answers live, from the browser to the database: how they're filled, evicted and invalidated, and how a cache cluster survives hot keys, stampedes and lost nodes, decided at the design level with runnable models.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Where to cache

Added

A cache is a copy of an answer kept closer to whoever asks for it. In a design interview the question is almost never "should we cache?"; it's "at which layer, keyed by what, and how stale may the copy be?" A request for a product page passes through half a dozen places that could each answer it from a copy: the user's browser, a CDN edge, the API gateway, the application process, a shared cache such as Redis, and the database's own memory. Each layer you answer from skips everything behind it.

A mental model that sticks: think of a chain of stores between a factory and a customer. The customer's own cupboard (the browser cache), a local shop (the CDN edge near them), the factory's loading dock (the gateway), a shelf next to the production line (memory inside the app server), the factory's stockroom (Redis), and the production line itself (the database). Every store you buy from saves the trips behind it. But the closer a store is to the customer, the more copies exist and the harder a recall is: you can call the stockroom and pull a bad batch in a second, but you can't reach into every customer's cupboard. That one trade-off, savings against control, decides most layer choices.

Each layer also sees a different amount of context. The browser and the CDN only see the HTTP request (URL, a few headers, cookies), so they can only cache what is the same for everyone who sends that request. The application sees the authenticated user and the business objects, so it can cache per user, per tenant or per fragment. The further in you go, the finer the key can be and the smaller the saving.

one user shared, keyed by the HTTP request inside your data centre, keyed by anything Browser HTTP cache CDN edge near the user Gateway response cache App memory per process (L1) Redis shared (L2) Database buffer pool A hit here saves … the whole network trip the long haul to the origin app CPU and everything after a network hop to Redis a database query a disk read biggest saving, most copies, hardest to purge smallest saving, one copy, easy to purge
Every layer can answer from a copy. Closer to the user saves more and is harder to take back.

What each layer is for, in the terms you'd use in a design review:

LayerKeyed byShared byHow you take a copy backGood for
Browser HTTP cacheURL (plus Vary headers)one useryou can't; change the URL insteadhashed static assets, the user's own data for a few seconds
CDN edgeURL plus the headers and cookies you chooseeveryone near that edgepurge API, cache tags, TTLimages, scripts, public pages, public API responses
Gateway or reverse proxypath and queryeveryone in the regionshort TTL, purgeexpensive public responses the CDN can't hold
App memory (L1)anything the code knowsone processshort TTL, or a broadcast to every servertiny, very hot, rarely changing data: config, feature flags, the top 100 products
Shared cache (L2)anything the code knowsevery app serverDEL the keymost application caching: rows, query results, rendered fragments
Database buffer poolpageseveryoneautomaticyou don't manage it, but it is why "warm" databases are fast

The model below puts numbers on the chain. It's a product page at a peak of 100,000 page-data requests a second, and each layer answers some fraction of whatever reaches it. The hit ratios and latencies are assumptions for the sketch, not measurements: 30% of views are repeats inside the browser's max-age, the CDN edge answers 80% of what reaches it, an in-process copy catches half of the rest, Redis 90% of what's left, and everything else is a database query. The latencies are typical rule-of-thumb figures (user to a nearby edge about 20 ms, edge to a distant origin about 60 ms more, a Redis call inside the data centre under a millisecond, an indexed query a few milliseconds); see Latency numbers to know for sourced figures.

layers.mjs
// Assumptions for a popular product page at peak (all stated in the prose).
const peakRps = 100_000;          // page-data requests per second from users
const layers = [                  // [name, hit ratio of what reaches it, ms to answer a hit from the user's view]
  ["browser cache", 0.30, 0],     // repeat views within max-age never leave the device
  ["CDN edge", 0.80, 20],         // user -> nearby edge round trip
  ["app memory", 0.50, 80],       // edge -> origin round trip, then a Map lookup
  ["Redis", 0.90, 81],            // + one Redis round trip inside the data centre
  ["database", 1.00, 90],         // + an indexed query that hits the buffer pool
];

let reaching = peakRps;
let latencySum = 0;
console.log("layer          reaches it   answered here   share of all");
for (const [name, hit, ms] of layers) {
  const answered = reaching * hit;
  latencySum += answered * ms;
  console.log(
    `${name.padEnd(14)} ${String(Math.round(reaching)).padStart(9)}/s ${String(Math.round(answered)).padStart(12)}/s ` +
      `${((answered / peakRps) * 100).toFixed(1).padStart(10)}%`,
  );
  reaching -= answered;
}
console.log(`mean latency seen by users: ${(latencySum / peakRps).toFixed(1)} ms`);

// What if one layer is switched off? Its traffic falls through to the next layer down.
for (const off of ["CDN edge", "Redis"]) {
  let r = peakRps;
  const load = {};
  for (const [name, hit] of layers) {
    load[name] = r;
    if (name !== off) r -= r * hit;
  }
  console.log(`without ${off.padEnd(8)}: origin gets ${Math.round(load["app memory"])}/s, database gets ${Math.round(load.database)}/s`);
}
Result
layer          reaches it   answered here   share of all
browser cache     100000/s        30000/s       30.0%
CDN edge           70000/s        56000/s       56.0%
app memory         14000/s         7000/s        7.0%
Redis               7000/s         6300/s        6.3%
database             700/s          700/s        0.7%
mean latency seen by users: 22.5 ms
without CDN edge: origin gets 70000/s, database gets 3500/s
without Redis   : origin gets 14000/s, database gets 7000/s
What’s happening
  1. Each layer only sees what the layer in front of it didn't answer. reaching starts at 100,000/s; the browser answers 30% (30,000/s) and passes 70,000/s on; the CDN answers 80% of those (56,000/s) and passes 14,000/s to the origin, and so on down to 700/s at the database.
  2. The "share of all" column is the real story: the browser and the CDN together answer 86% of all traffic, and your servers never see it. Redis, the cache everyone draws first, answers only 6.3% of user requests, yet it's still what stands between 7,000/s and 700/s at the database.
  3. The mean latency is a weighted average: 30% of requests take 0 ms, 56% take 20 ms, and only the 14% that reach the origin pay 80–90 ms. That gives 22.5 ms, against roughly 90 ms if every request went all the way back.
  4. Switching off the CDN multiplies origin traffic by 5 (14,000/s becomes 70,000/s) and the database load by 5 too (700/s becomes 3,500/s), because every layer behind it receives more. Switching off Redis leaves the origin alone but multiplies the database load by 10, which is exactly 1 / (1 − 0.9).
  5. So "add a cache" is really "decide which layer absorbs which traffic". The outer layers cut user latency and origin traffic; the inner layers cut database load. If the model's 80% CDN hit ratio became 95%, the origin would drop from 14,000/s to 3,500/s, which is often worth more than any server-side cache.
AdvancedWhat each layer can't do

The browser can't be purged. Once a response is stored with Cache-Control: max-age=31536000, nothing you do on the server removes it from a user's disk before then. That's why static assets get a content hash in the file name (app.3f9a1c.js): a new version is a new URL, so the old copy simply stops being asked for. immutable (RFC 8246) tells the browser not even to revalidate such a file on reload. HTML, which must change when you deploy, gets a short max-age or no-cache (store it, but revalidate every time).

A shared cache must not see personal data. The CDN and gateway answer everyone who sends the same cache key. A response that depends on the cookie but is cached without the cookie in its key is served to the next visitor. Mark per-user responses Cache-Control: private (MDN: shared caches must not store it) and keep the public part of the page separate from the personal part.

The CDN only knows the request. Its cache key is the URL plus whatever headers or cookies you add to it, so every extra dimension multiplies the number of copies: varying on Accept-Language with 40 languages turns one cached page into 40, each with 1/40th of the traffic and so a far lower hit ratio. Normalise keys at the edge (drop utm_* tracking parameters, sort query parameters, collapse languages to the ones you actually serve) so equivalent requests share one copy.

An in-process cache multiplies by the number of servers. Fifty app servers mean fifty copies of every L1 entry, fifty misses to warm it, and fifty places to invalidate. Keep L1 for small, very hot, slow-changing things (feature flags, configuration, the hottest keys during a spike) with a TTL of seconds, and let the shared cache hold everything else.

The database cache isn't yours to tune key by key, but it explains a familiar effect: right after a restart the database is slow because its buffer pool is cold, which is the same warm-up problem as an empty Redis (see Distributed caches).

How long a CDN keeps an object is mostly up to your headers. On CloudFront, for example, s-maxage (or max-age) sets the edge TTL within the cache policy's minimum and maximum, and with no cache policy and no header the default TTL is 24 hours (AWS CloudFront docs, checked October 2026). CloudFront also honours stale-while-revalidate and stale-if-error (RFC 5861), which let the edge keep serving an expired copy while it refetches, or while the origin is down. Those two directives are the edge's built-in answer to the stampede and outage problems in Hot keys and cache stampedes.

AdvancedCaching "not found" and errors

A miss for something that doesn't exist goes to the database every time, because there's nothing to cache. A bot requesting random product IDs turns your cache into a pass-through. The fix is negative caching: store a small "not found" marker with a short TTL (say 30–60 seconds), so repeated lookups for the same missing ID stop at the cache. Keep the TTL short so a newly created item appears quickly, and delete the marker when the item is created. For huge ID spaces, a Bloom filter of existing IDs in front of the cache rejects most nonexistent IDs without storing a marker per ID.

Errors are different: don't cache a 500 as if it were the answer, or a one-second blip becomes a minute-long outage. Do the opposite: keep serving the last good copy while the origin is failing (stale-if-error at the edge, or a "serve stale on error" path in the app), so a downstream failure degrades freshness instead of availability.

A page that's 95% the same for everyone

A product page is identical for all visitors except a small "3 items in your cart" badge in the header. The team wants the CDN to cache the page, but the badge comes from the session cookie. What do you do?

Show answer

Cache the page without the personal part, and fetch the personal part separately.

What’s happening
  1. If the page is cached with the cookie in the cache key, every visitor has a different key, so every request misses: the CDN hit ratio drops to near zero and you've paid for an edge cache that does nothing.
  2. If it's cached without the cookie in the key, the first visitor's badge ("3 items") is shown to everyone. That's a correctness bug, and with names or addresses it's a privacy incident.
  3. The fix is to split the response: the HTML for the product is public (Cache-Control: public, s-maxage=60, stale-while-revalidate=300, cookies stripped from the cache key), and the badge is filled in by a small uncached call such as GET /api/cart/summary with Cache-Control: private, no-store.
  4. The edge now answers the expensive part for everyone, and the origin only serves the tiny per-user call. Some CDNs can also stitch the personal fragment in at the edge (edge-side includes or an edge function), which saves the extra round trip but keeps the same split.
  5. The general rule this shows: separate what varies per user from what doesn't, and cache each at the outermost layer that can hold it safely.

#Caching strategies

Added

A caching strategy answers two questions. On a read miss, who fetches the data and fills the cache? Either your application does it (cache-aside) or the cache layer does it for you (read-through). On a write, where does the new value go first, and when is the caller told "done"? The database first and the cached copy dropped (cache-aside's write half, also called write-around), both together before acknowledging (write-through), or the cache first and the database later (write-behind). Refresh-ahead adds a third question: do you wait for a copy to expire, or reload it just before?

The mental model is an assistant at the front desk (the cache) and the archive room in the basement (the database). Cache-aside: you check the desk yourself; if the file isn't there, you walk to the archive, fetch it, and leave a copy on the desk for next time. Read-through: you only ever ask the assistant, who goes to the archive for you. Write-through: you hand a change to the assistant, who files it in the archive before saying "done". Write-behind: the assistant says "done" at once and files the day's changes in one trip at five o'clock, which is fast and saves trips, but if the building burns down at four, the day's changes are gone. Refresh-ahead: the assistant notices the popular files are about to go out of date and re-copies them before anyone asks.

Cache-aside (lazy loading) App Cache Database 1, 3 2 1 GET misses · 2 app reads the DB · 3 app SETs write: update the DB, then DEL the key Read-through App Cache Database 1 2 1 app only ever asks the cache 2 on a miss the cache loads from the DB itself Write-through App Cache Database 1 2 1 write to the cache · 2 cache writes the DB ack only after both: fresh, but slower writes Write-behind (write-back) App Cache Database 1 2 1 write to the cache, ack at once 2 flushed to the DB in batches: lost on a crash
Who talks to the database, and when the write is acknowledged, is the whole difference between the patterns.
PatternRead missWriteFreshnessWhat can go wrongTypical use
Cache-asideapp reads DB, then sets the cacheupdate DB, then delete the keystale until TTL or deletedelete-after-write race; first read after a write is a missthe default for application caching
Read-throughcache loads from DB(combine with one of the write patterns)same as cache-asidethe cache library needs a loader and DB accessloading caches (Caffeine's LoadingCache), caching proxies
Write-throughrarely misses for written keyswrite cache and DB, ack after bothfresh for written keysslower writes; caches data nobody readsread-after-write heavy data such as profiles and settings
Write-behindfrom cachewrite cache, flush DB latercache is the newest copydata loss if the cache dies; ordering; the DB lagscounters, view counts, metrics, game state
Refresh-aheadreload before expiry(any)fresher copies, no expiry stallreloads keys nobody reads againhot, expensive, predictable keys

Read-through and cache-aside cost the same database reads; the difference is who owns the loading code. With cache-aside every caller repeats "get, miss, read, set", and each can do it slightly differently. With read-through the loader lives in one place, which is also the natural place to put request coalescing (see Hot keys and cache stampedes). The Node.js and Databases courses implement cache-aside, write-through and write-behind against real Redis and MySQL; here the question is which to choose.

The model below compares the two read strategies on one workload. Fifty keys (a product catalogue's front page, say) are read 200 times a second for ten minutes, with a Zipf-like popularity: key 0 is twice as popular as key 1, three times as popular as key 2, and so on. A hit costs 1 ms; a miss costs 1 ms plus a 20 ms database read that the user waits for. The TTL is 30 seconds. Refresh-ahead reloads a key in the background when a read finds it in the last 20% of its TTL. The workload uses a seeded PRNG (mulberry32), so every run prints the same numbers.

read-strategies.mjs
// Seeded PRNG (mulberry32) so every run prints the same numbers.
function mulberry32(a) {
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const KEYS = 50, TTL = 30_000, DB_MS = 20, HIT_MS = 1;
const weights = Array.from({ length: KEYS }, (_, i) => 1 / (i + 1));   // key 0 is the most popular
const total = weights.reduce((a, b) => a + b);
function pickKey(rand) {
  let r = rand() * total;
  for (let i = 0; i < KEYS; i++) if ((r -= weights[i]) <= 0) return i;
  return KEYS - 1;
}

function run(strategy) {
  const rand = mulberry32(42);
  const cache = new Map();                                // key -> time it was loaded
  let dbReads = 0, slowReads = 0, latency = 0, reads = 0;
  for (let now = 0; now < 600_000; now += 5) {            // 10 minutes, one read every 5 ms = 200 reads/s
    const key = pickKey(rand);
    const loadedAt = cache.get(key);
    reads++;
    if (loadedAt === undefined || now - loadedAt >= TTL) {
      dbReads++; slowReads++; latency += HIT_MS + DB_MS;  // miss: the user waits for the database
      cache.set(key, now);
      continue;
    }
    latency += HIT_MS;                                    // hit
    if (strategy === "refresh-ahead" && now - loadedAt >= TTL * 0.8) {
      dbReads++;                                          // background reload; nobody waits for it
      cache.set(key, now);
    }
  }
  return { dbReads, slowReads, reads, avg: latency / reads };
}

for (const s of ["cache-aside", "refresh-ahead"]) {
  const r = run(s);
  console.log(`${s.padEnd(13)} reads ${r.reads}  db reads ${r.dbReads}  slow reads ${r.slowReads} ` +
    `(${((r.slowReads / r.reads) * 100).toFixed(2)}%)  avg ${r.avg.toFixed(2)} ms`);
}
Result
cache-aside   reads 120000  db reads 1000  slow reads 1000 (0.83%)  avg 1.17 ms
refresh-ahead reads 120000  db reads 1243  slow reads 51 (0.04%)  avg 1.01 ms
What’s happening
  1. Both runs replay the same 120,000 reads (one every 5 ms for 600,000 ms), because each creates mulberry32(42) afresh. Only the strategy differs.
  2. Cache-aside misses whenever a key is absent or older than 30 s. Every key is read often enough to be reloaded right after it expires, so each of the 50 keys misses about once per 30 s: 50 keys × 20 TTL periods = 1,000 database reads, and 1,000 users who waited 21 ms. The average looks great (1.17 ms), but 0.83% of requests are slow, which is what shows up in p99 latency.
  3. Refresh-ahead checks every hit: if the copy is older than 24 s (80% of the TTL), it reloads it in the background and the reader still gets the cached copy at once. Hot keys are read many times in that 6-second window, so they never actually expire.
  4. Its 51 slow reads are the 50 cold first loads plus one expiry: key 46, read only rarely, happened not to be read during its refresh window, so it expired normally. That's the weakness of refresh-ahead: it only helps keys that are read often enough to trigger the refresh.
  5. The price is 24% more database reads (1,243 against 1,000), because a key is reloaded at about 24 s instead of 30 s and some refreshed copies are never read again. You trade a little database load for removing expiry stalls from the user's path.
  6. If you raised the refresh point from 80% to 95% of the TTL, extra reads would fall, but the window would shrink to 1.5 s, and more of the less popular keys would expire before anyone triggered the refresh.
AdvancedWrite strategies compared on one workload

Writes are where the patterns really differ. The next model runs a mixed workload against a cache too small to hold everything: 1,000 keys with Zipf popularity, an LRU cache of 300 entries, one operation per millisecond for 10 seconds, 80% reads and 20% writes. A database write costs 10 ms and a cache operation 1 ms. Write-behind flushes its dirty keys once a second (and flushes a dirty key early if LRU is about to evict it), and the model records how many unflushed writes a cache crash at 9.5 s would lose.

write-strategies.mjs
function mulberry32(a) {                                   // seeded PRNG: same numbers every run
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const KEYS = 1000, CAPACITY = 300, DB_MS = 10, CACHE_MS = 1, FLUSH_EVERY = 1000;
const w = Array.from({ length: KEYS }, (_, i) => 1 / (i + 1));
const total = w.reduce((a, b) => a + b);
const pick = (r) => { let x = r() * total; for (let i = 0; i < KEYS; i++) if ((x -= w[i]) <= 0) return i; return KEYS - 1; };

function run(strategy) {
  const rand = mulberry32(7);
  const cache = new Map();                                 // insertion order = LRU order
  const dirty = new Set();                                 // write-behind: written to cache, not yet to the DB
  let dbWrites = 0, misses = 0, reads = 0, writeMs = 0, writes = 0, lostAtCrash = 0;
  const put = (k, v) => {
    cache.delete(k); cache.set(k, v);
    if (cache.size <= CAPACITY) return;
    const victim = cache.keys().next().value;              // least recently used
    cache.delete(victim);
    if (dirty.delete(victim)) dbWrites++;                  // write-behind must flush a dirty victim first
  };
  for (let now = 1; now <= 10_000; now++) {                // 10 s, one operation per ms
    const key = pick(rand);
    if (rand() < 0.8) {                                    // 80% reads
      reads++;
      if (cache.has(key)) put(key, cache.get(key));
      else { misses++; put(key, "row"); }                  // miss: load the row from the DB
    } else {
      writes++;
      if (strategy === "cache-aside") { dbWrites++; cache.delete(key); writeMs += DB_MS + CACHE_MS; }
      if (strategy === "write-through") { dbWrites++; put(key, "new"); writeMs += DB_MS + CACHE_MS; }
      if (strategy === "write-behind") { put(key, "new"); dirty.add(key); writeMs += CACHE_MS; }
    }
    if (strategy === "write-behind") {
      if (now === 9_500) lostAtCrash = dirty.size;         // what a crash at 9.5 s would lose
      if (now % FLUSH_EVERY === 0) { dbWrites += dirty.size; dirty.clear(); }  // one DB write per dirty key
    }
  }
  return `${strategy.padEnd(13)} writes ${writes}  db writes ${String(dbWrites).padStart(4)}  ` +
    `avg write ${(writeMs / writes).toFixed(1).padStart(4)} ms  read miss ${((misses / reads) * 100).toFixed(1)}%  ` +
    `lost if cache dies at 9.5 s: ${lostAtCrash}`;
}
for (const s of ["cache-aside", "write-through", "write-behind"]) console.log(run(s));
Result
cache-aside   writes 1917  db writes 1917  avg write 11.0 ms  read miss 34.9%  lost if cache dies at 9.5 s: 0
write-through writes 1917  db writes 1917  avg write 11.0 ms  read miss 23.3%  lost if cache dies at 9.5 s: 0
write-behind  writes 1917  db writes 1052  avg write  1.0 ms  read miss 23.3%  lost if cache dies at 9.5 s: 64
What’s happening
  1. All three replay the same 10,000 operations (mulberry32(7)), of which 1,917 happen to be writes. What changes is what a write does to the cache and the database.
  2. Cache-aside writes the database and deletes the key, so the next read of that key is a guaranteed miss. With hot keys written often, that adds up: 34.9% of reads miss. Every write pays the 10 ms database write plus a 1 ms delete.
  3. Write-through writes the new value into the cache as well as the database, so the next read hits. Read misses fall to 23.3% (the misses left are the cold, rarely used keys that don't fit in 300 entries). Writes cost the same 11 ms, because the caller still waits for the database.
  4. Write-behind acknowledges after the cache write alone (1 ms), and the once-a-second flush writes each dirty key once however many times it changed in that second. Hot keys change several times a second, so 1,917 writes become only 1,052 database writes, a 45% saving. Its read misses equal write-through's, since in both the cache holds the newest value.
  5. The cost is in the last column: at 9.5 s there are 64 keys whose newest value exists only in the cache. If the cache node dies then, those writes are gone, even though every caller was told they succeeded. That's why write-behind needs a durable cache (replicated, with an append-only log) or data whose loss is tolerable.
  6. Notice what write-through doesn't fix: it caches every written key, including ones nobody reads, and with two writers it can still leave the older value in the cache if their cache writes land in the opposite order from their database commits. That's why plain cache-aside invalidates by deleting, not updating (see Cache invalidation and consistency).
AdvancedChoosing a strategy in the interview

Choosing in the interview

Start from the default: cache-aside with delete-on-write and a TTL. It's simple, the cache can be lost at any moment without losing data (it only holds copies), and the TTL bounds how wrong a copy can be. Then change it only for a reason you can name:

  • The same user reads right after writing (edit profile, then the profile page reloads): write-through for those keys, or keep the delete and accept one miss. Both are fine; say which and why.
  • Writes are far more frequent than reads and individually unimportant (view counts, likes, "last seen" timestamps, game scores): write-behind. Increment in Redis, flush aggregated deltas to the database every few seconds. Losing a few seconds of view counts in a crash is acceptable; losing a payment is not.
  • A few keys are hot and expensive to compute (the home page's "trending" list, a leaderboard top 100): refresh-ahead from a scheduled job or on access, so the expensive recompute never happens on a user's request.
  • Many services share the same loading logic: put it behind a read-through layer (a small cache service or a library with a loader) so coalescing, TTLs and metrics live in one place.
Pick a strategy for each

Choose a write strategy for: (a) a video's view counter shown on the watch page, (b) a user's notification settings, (c) the balance shown during a money transfer.

Show answer

(a) write-behind, (b) cache-aside or write-through, (c) don't serve it from the cache at all.

What’s happening
  1. View counter: hundreds of increments a second on a popular video, each individually worthless. INCR in Redis absorbs them, and a flusher adds the accumulated delta to the database every few seconds. Losing a few seconds of counts in a crash is invisible to users. Synchronous database writes for each view would be the bottleneck.
  2. Notification settings: written rarely, read on every notification send. Cache-aside (delete on write) is fine; write-through is also fine and avoids one miss right after a change. Either way, the database is written synchronously, so a cache loss loses nothing.
  3. Balance during a transfer: the decision ("is there enough money?") must use the authoritative value inside the database transaction, never a cached copy. You can still cache the balance for the account overview page with a short TTL, but the transfer path reads and locks the row in the database.
  4. The pattern: the cost of a stale or lost value picks the strategy. Cheap to lose: write-behind. Cheap to be briefly stale: cache-aside. Must be exact: read the source of truth.

#Eviction policies: LRU, LFU and TTL

Added

A cache is almost always smaller than the data behind it, so when it's full and a new entry arrives, something has to go. The eviction policy is that choice, and it's really a prediction: "which of these entries is least likely to be asked for again soon?" LRU (least recently used) bets that what you touched recently you'll touch again. LFU (least frequently used) bets that what has been popular stays popular. Neither knows the future; each is right for a different shape of traffic.

TTL is a different thing that people often lump in with eviction. A TTL (time to live) removes an entry because it may be wrong, not because space is needed: it's a freshness bound. A real cache usually has both: a TTL on each key so copies can't be stale for ever, and a capacity policy that decides who goes when memory runs out before the TTLs do.

The mental model: a small bookshelf beside your desk, with the library down the hall. LRU keeps the books you've opened most recently, and when the shelf is full the one untouched the longest goes back. LFU keeps the books you've opened most often overall, which protects your favourite reference book even if you haven't opened it this week, but also keeps last term's textbook long after you've stopped needing it. TTL is a "return by" date stamped on each book, whatever you think of it.

The shape of real traffic matters, so the model uses a Zipf-like popularity distribution, which is what most read traffic looks like (a few items get most of the requests, and a long tail gets a few each): the item ranked i is requested in proportion to 1/i^0.9 across 10,000 keys. It then runs three workloads against LRU, LFU and an LFU whose counts are halved every 5,000 requests (decay), at two cache sizes (1% and 5% of the keys). The PRNG is seeded (mulberry32), so the numbers are reproducible.

  • steady Zipf: the same popularity for all 200,000 requests.
  • Zipf + scans: 30% of requests are one-off keys never seen again, like a nightly batch job or a crawler reading through everything once.
  • shifting hot set: halfway through, popularity moves to a different set of keys, like yesterday's news being replaced by today's.
eviction.mjs
function mulberry32(a) {                                    // seeded PRNG: same numbers every run
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
class LRU {                                                 // a Map remembers insertion order: oldest first
  constructor(cap) { this.cap = cap; this.m = new Map(); }
  get(k) {
    if (!this.m.has(k)) return false;
    this.m.delete(k); this.m.set(k, true);                  // move to the "most recent" end
    return true;
  }
  put(k) { this.m.set(k, true); if (this.m.size > this.cap) this.m.delete(this.m.keys().next().value); }
}
class LFU {                                                 // O(1) LFU: count -> keys with that count
  constructor(cap, decayEvery = 0) {
    Object.assign(this, { cap, decayEvery, ops: 0, count: new Map(), buckets: new Map(), min: 0 });
  }
  #decay() {                                                // halve every count so old popularity fades
    this.buckets.clear();
    for (const [k, c] of this.count) {
      const h = Math.max(1, c >> 1);
      this.count.set(k, h);
      if (!this.buckets.has(h)) this.buckets.set(h, new Set());
      this.buckets.get(h).add(k);
    }
    this.min = Math.min(...this.buckets.keys());
  }
  #bump(k, c) {
    this.buckets.get(c)?.delete(k);
    if (this.buckets.get(c)?.size === 0) { this.buckets.delete(c); if (this.min === c) this.min = c + 1; }
    this.count.set(k, c + 1);
    if (!this.buckets.has(c + 1)) this.buckets.set(c + 1, new Set());
    this.buckets.get(c + 1).add(k);
  }
  get(k) {
    if (this.decayEvery && ++this.ops % this.decayEvery === 0) this.#decay();
    if (!this.count.has(k)) return false;
    this.#bump(k, this.count.get(k));
    return true;
  }
  put(k) {
    if (this.count.size >= this.cap) {                      // evict the least-used key (oldest among ties)
      const victims = this.buckets.get(this.min);
      const v = victims.values().next().value;
      victims.delete(v); if (victims.size === 0) this.buckets.delete(this.min);
      this.count.delete(v);
    }
    this.count.set(k, 0); this.min = 1; this.#bump(k, 0);
  }
}
const N = 10_000, REQUESTS = 200_000;
const cdf = []; let acc = 0;
for (let i = 1; i <= N; i++) cdf.push((acc += 1 / i ** 0.9));     // Zipf-like popularity, s = 0.9
const zipf = (r) => { const x = r() * acc; let lo = 0, hi = N - 1;
  while (lo < hi) { const mid = (lo + hi) >> 1; if (cdf[mid] < x) lo = mid + 1; else hi = mid; } return lo; };

const workloads = {
  "steady Zipf": (i, r) => zipf(r),
  "Zipf + scans": (i, r) => (i % 10 < 3 ? 1_000_000 + i : zipf(r)),  // 30% one-off keys (a batch job)
  "shifting hot set": (i, r) => (zipf(r) + (i < REQUESTS / 2 ? 0 : 5_000)) % N,  // popularity moves at half time
};
for (const cap of [100, 500]) {
  for (const [name, next] of Object.entries(workloads)) {
    const row = [];
    for (const [label, make] of [["LRU", () => new LRU(cap)], ["LFU", () => new LFU(cap)],
                                 ["LFU+decay", () => new LFU(cap, 5_000)]]) {
      const rand = mulberry32(1), c = make(); let hits = 0;
      for (let i = 0; i < REQUESTS; i++) { const k = next(i, rand); if (c.get(k)) hits++; else c.put(k); }
      row.push(`${label} ${((hits / REQUESTS) * 100).toFixed(1).padStart(4)}%`);
    }
    console.log(`cache ${String(cap).padStart(3)} keys  ${name.padEnd(16)}  ${row.join("   ")}`);
  }
}
Result
cache 100 keys  steady Zipf       LRU 26.2%   LFU 38.4%   LFU+decay 40.0%
cache 100 keys  Zipf + scans      LRU 15.4%   LFU 26.4%   LFU+decay 27.5%
cache 100 keys  shifting hot set  LRU 26.2%   LFU 19.7%   LFU+decay 37.4%
cache 500 keys  steady Zipf       LRU 45.5%   LFU 54.2%   LFU+decay 52.7%
cache 500 keys  Zipf + scans      LRU 27.5%   LFU 37.6%   LFU+decay 33.9%
cache 500 keys  shifting hot set  LRU 45.4%   LFU 32.6%   LFU+decay 52.2%
What’s happening
  1. LRU is a Map used as an ordered list: a hit deletes and re-inserts the key so it moves to the "newest" end, and when the map is over capacity the first key in iteration order (the least recently used) is deleted. Both operations are O(1).
  2. LFU keeps a count per key and a bucket of keys per count, plus min, the lowest count present. A hit moves the key from bucket c to c + 1; eviction takes the oldest key from bucket min. A new key enters with count 1, which resets min to 1. That's the classic O(1) LFU design. LFU+decay halves every count every 5,000 requests, so past popularity fades.
  3. Steady Zipf, 100 slots: LFU gets 38.4% against LRU's 26.2%. The best any fixed set of 100 keys can do here is to hold the 100 most popular, which receive 41.0% of requests (the top 500 receive 58.6%), so LFU is close to the ceiling. LRU loses because a burst of tail keys pushes popular ones out between their requests.
  4. Zipf + scans: 30% of requests can never hit, so the ceiling drops to about 70% of the steady numbers. LRU suffers most (15.4%): every one-off key is inserted as "most recent" and pushes a genuinely hot key one step closer to eviction. LFU shrugs them off because each enters with count 1 and is the next victim. This is scan resistance, and it's why databases' buffer pools and modern caches don't use plain LRU.
  5. Shifting hot set: now plain LFU is the loser (19.7% at 100 slots, below LRU's 26.2%). The old favourites have huge counts and stay in the cache long after anyone asks for them, while the new hot keys enter at count 1 and are evicted before they can build up a count. Decay fixes that: with counts halved every 5,000 requests, the stale favourites lose their lead and LFU+decay reaches 37.4%.
  6. Decay isn't free. On the steady workload at 500 slots it costs a little (52.7% against 54.2%), because it throws away history that was still accurate. The decay rate is a knob: how quickly should yesterday's popularity stop counting? Redis exposes exactly that knob (below).
AdvancedWhich policy suits which workload

Which policy suits which workload, in the terms you'd use in a design discussion:

Traffic shapeGood choiceWhy
Recent items are the hot items (feeds, sessions, chat history, "recently viewed")LRUrecency is the signal; popularity shifts constantly
Stable popularity with a long tail (product catalogue, user profiles, static content)LFU with decayprotects the steady favourites from bursts of tail requests
Scans or one-off reads mixed with hot keys (batch jobs, crawlers, analytics)LFU or an admission filterone-off keys shouldn't push hot ones out
All keys equally likely (round-robin over a dataset)randomnothing to predict; LRU just costs more
Data that goes wrong after a known time (prices, tokens, schedules)TTL, plus any of the abovefreshness is a correctness rule, not a capacity choice
AdvancedWhat real caches actually run

Production caches approximate these policies to save memory and CPU, and the details come up in interviews.

Redis samples instead of keeping a list. Its allkeys-lru doesn't maintain a linked list of every key. It picks a few keys at random (maxmemory-samples, default 5), evicts the oldest of the sample, and since Redis 3.0 keeps a small pool of good candidates; the docs say 10 samples is very close to true LRU. Its LFU (Redis 4.0+) uses a Morris counter, a probabilistic logarithmic counter that fits in 8 bits, with two knobs: lfu-log-factor (default 10: about a million hits saturate the counter) and lfu-decay-time (default 1: the counter is reduced every minute a key isn't touched). That decay is the same idea as LFU+decay in the model. (Redis eviction docs, checked October 2026.)

Redis's policy list in 2026 is noeviction (refuse writes when full), allkeys-lru, allkeys-lfu, allkeys-random, the volatile-* versions that only consider keys with a TTL, volatile-ttl (shortest remaining TTL first), and, new in Redis 8.6, allkeys-lrm and volatile-lrm (least recently modified: reads don't refresh the timestamp, only writes do). The docs' rule of thumb is allkeys-lru as the default when a subset of keys is much hotter than the rest.

Caffeine (the Java cache library) uses Window TinyLFU: new entries go into a small LRU "window" where they can prove themselves, and only enter the main segmented-LRU area if a compact frequency sketch (a 4-bit count-min sketch, about 8 bytes per entry) says they're used more often than the entry they'd replace. The window/main split adapts by hill climbing. Its wiki reports near-optimal hit rates on traces from Wikipedia, search and databases, competitive with ARC and LIRS. The idea to take into an interview: admission (should this new key get in at all?) matters as much as eviction (who leaves?), and it's what makes a cache scan-resistant.

Size matters, not just count. The model counts entries, but real caches are bounded in bytes, and one 900 KB value costs as much as nine thousand 100-byte values. Size-aware policies weigh the benefit of keeping an entry by its size. Memcached also caps item size: 1 MB by default, adjustable with -I up to 1 GB (memcached man page, 2026). Keep cached values small and split large ones.

AdvancedChoosing TTLs

A TTL should come from a staleness budget: how long can this copy be wrong before a user or the business notices? A product description can be wrong for an hour; a price for a minute; stock levels on a product page for 30 seconds, but never during checkout, which reads the database. Write that number down per key type in the design rather than using one global TTL.

Three rules of thumb:

  • Short TTLs cost load, long TTLs cost staleness. Each halving of the TTL roughly doubles the reloads of hot keys. The next topic measures the trade.
  • Use the TTL as the safety net, not the mechanism. If correctness matters, delete on write and keep the TTL as the bound for when an invalidation is lost.
  • Add jitter. Keys written together (after a deploy or a cache restart) expire together. A random ±10% on each TTL spreads their reloads; Hot keys and cache stampedes measures the difference.
LRU or LFU?

A cache holds 2 entries. Requests arrive in this order: A A A B C B C B C. How many hits does LRU get, and how many does LFU get (evicting the oldest entry among equal counts)?

Show answer

LRU gets 6 hits; LFU gets only 2.

What’s happening
  1. Both start the same way: A misses and is stored, then A A are two hits. LFU now has A with a count of 3.
  2. B misses and fills the second slot. C misses and the cache is full. LRU evicts the least recently used entry, which is A (last used before B). LFU evicts the lowest count, which is B (count 1, against A's 3).
  3. LRU now holds B and C, exactly the two keys the rest of the sequence alternates between: B C B C are four more hits, 6 in total.
  4. LFU holds A (count 3) and C (count 1). B misses and evicts C, then C misses and evicts B, and so on: each new key is evicted before it can build a count higher than A's 3. The rest of the sequence is all misses, 2 hits in total.
  5. This is the "shifting hot set" result from the model in miniature: LFU without decay keeps an old favourite for ever and starves the new hot keys. Halve the counts periodically and A would eventually lose its place.

#Cache invalidation and consistency

Added

A cache is a copy, and every copy can disagree with the original. Invalidation is how you bound that disagreement. In a design you need to say two things: how stale may a read be (seconds? never?), and which mechanism enforces that bound. There are three mechanisms, and real systems combine them:

  • TTL: every copy dies after a fixed time, whatever happens. Simple, needs no coordination, and puts a hard upper bound on staleness, but the copy is wrong for up to the whole TTL after every change, and short TTLs mean many reloads.
  • Explicit invalidation: when the data changes, something deletes the affected keys. Copies are fresh almost immediately, but now the writer (or a pipeline) must know every key a change affects, and a lost or reordered delete leaves a stale copy until the TTL catches it.
  • Versioned keys: put a version in the key name (product:42:v7, or a namespace version for a whole user). A change bumps the version, readers build the new name, and the old copies are never asked for again. Nothing has to be found and deleted.

The mental model: TTL is a "best before" date printed on every copy. Explicit invalidation is a product recall: fast, but you have to reach every shop holding the batch, and a recall letter can get lost. Versioned keys are a new edition with a new ISBN: you don't recall the old edition at all; nobody orders it any more, and the old stock is eventually cleared out (by TTL or eviction).

First, the trade between TTL alone and delete-on-write, measured. One hot key is read 100 times a second for a simulated day, and the row behind it changes at random about once a minute (a Poisson process from a seeded PRNG, so 1,474 updates in this run). For each setup the model counts database reads, the share of reads that returned an out-of-date copy, and the oldest copy anyone was served.

ttl-staleness.mjs
function mulberry32(a) {                                    // seeded PRNG: same numbers every run
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const DAY = 86_400_000, READ_EVERY = 10, MEAN_UPDATE_GAP = 60_000;   // 100 reads/s, an update about once a minute
const rand = mulberry32(3);
const updates = [];                                          // times the row changes in the database
for (let t = 0; ; ) { t += -Math.log(1 - rand()) * MEAN_UPDATE_GAP; if (t > DAY) break; updates.push(Math.round(t)); }

function run(ttl, deleteOnWrite) {
  let loadedAt = -Infinity, u = 0, lastUpdate = -Infinity, dbReads = 0, stale = 0, reads = 0, worst = 0;
  let firstMissedUpdate = null;                              // the oldest change the cached copy doesn't have
  for (let now = 0; now < DAY; now += READ_EVERY) {
    while (u < updates.length && updates[u] <= now) {
      lastUpdate = updates[u];
      if (deleteOnWrite) loadedAt = -Infinity;               // the writer deletes the key after committing
      else if (firstMissedUpdate === null && loadedAt > -Infinity) firstMissedUpdate = lastUpdate;
      u++;
    }
    reads++;
    if (now - loadedAt >= ttl) { loadedAt = now; dbReads++; firstMissedUpdate = null; continue; }
    if (loadedAt < lastUpdate) { stale++; worst = Math.max(worst, now - firstMissedUpdate); }
  }
  const label = deleteOnWrite ? `delete on write + TTL ${ttl / 1000}s` : `TTL only ${ttl / 1000}s`;
  console.log(`${label.padEnd(26)} db reads/hour ${String(Math.round(dbReads / 24)).padStart(4)}  ` +
    `stale reads ${((stale / reads) * 100).toFixed(2).padStart(5)}%  worst staleness ${(worst / 1000).toFixed(1)} s`);
}
console.log(`${updates.length} updates in 24 h, ${DAY / READ_EVERY} reads`);
for (const ttl of [5_000, 30_000, 300_000]) run(ttl, false);
run(300_000, true);
Result
1474 updates in 24 h, 8640000 reads
TTL only 5s                db reads/hour  720  stale reads  4.21%  worst staleness 5.0 s
TTL only 30s               db reads/hour  120  stale reads 22.46%  worst staleness 30.0 s
TTL only 300s              db reads/hour   12  stale reads 81.22%  worst staleness 299.9 s
delete on write + TTL 300s db reads/hour   62  stale reads  0.00%  worst staleness 0.0 s
What’s happening
  1. The model generates the update times first: gaps drawn from an exponential distribution with a 60-second mean, which is how independent random events (a Poisson process) are spaced. That gives 1,474 updates in 24 hours, and the same 8,640,000 reads (one every 10 ms) are replayed for every setup.
  2. TTL only: the copy is reloaded when it's older than the TTL, regardless of updates. A read is stale if an update happened after the copy was loaded. With a 5 s TTL, 4.21% of reads are stale and the worst is 5.0 s out of date, at 720 database reads an hour (one per TTL).
  3. Lengthening the TTL cuts load in exact proportion (30 s gives 120 reads an hour, 300 s gives 12) but staleness grows fast: with a 300 s TTL 81% of reads are stale, because with an update every minute on average the copy is almost always older than the latest change. The worst staleness always equals the TTL: that's the guarantee a TTL gives, and the only one.
  4. Delete on write removes the copy whenever the row changes, so the next read reloads it: 0% stale in this model, at 62 reads an hour (one per update, plus the rare TTL expiry when no update came for 5 minutes). That's fewer reloads than even the 30 s TTL, with fresher data.
  5. The lesson: when updates are rarer than reads, invalidating on change beats any TTL on both axes. TTL-only only wins when the writer can't tell the cache (a third-party API, data owned by another team) or when data changes constantly and some staleness is fine.
  6. The model's 0% is an ideal. A real delete can be lost (the cache was briefly unreachable), arrive late, or lose a race with a slow reader, which is why the TTL stays on as the safety net; the next sections cover those failures.
AdvancedThe race that delete-on-write still has

The textbook write is "update the database, then delete the key". It has one well-known race, which the Node.js and Databases courses reproduce against real Redis and MySQL. In outline:

  1. Reader A misses the cache and reads the row (price 100) from the database.
  2. Writer B updates the row to 80, commits, and deletes the cache key.
  3. A, delayed for a few milliseconds by a GC pause or a slow network, now sets the cache to the 100 it read before.
  4. The cache says 100 and the database says 80, and nothing will correct it until the TTL expires.

The window is small (A's read must start before B's commit and its set must land after B's delete), but at thousands of requests a second on hot keys it happens. The design-level fixes, from cheapest to strongest:

  • Keep a TTL on every key. It doesn't prevent the race; it bounds the damage to one TTL.
  • Delayed double delete: the writer deletes again a short time later (say a second, longer than a typical read takes). It narrows the window further but is a heuristic, not a guarantee.
  • Leases (Facebook's memcache): on a miss, the cache hands the reader a 64-bit token bound to the key; a later delete invalidates the token, and the cache refuses a set with an invalidated token. A's stale set in step 3 is simply rejected. The same token rate-limits refills, which is the stampede fix in the next topic.
  • Versioned writes: store a version (or the row's update timestamp) with the cached value and only accept a set if it's newer than what's there, atomically in the cache (a Lua script in Redis). The Databases course builds this.
  • Change data capture (CDC): derive invalidations from the database's own commit log instead of from application code, as below.

And the order matters. Delete before writing is worse: between the delete and the commit, any reader refills the cache with the old value, and that's a much wider window than the race above. Update the cache instead of deleting is also worse with concurrent writers: two writers can commit in one order and update the cache in the other, leaving the older value cached. Facebook's paper gives the short reason they delete: deletes are idempotent, so they can be retried and repeated safely.

AdvancedInvalidation from the commit log

Asking every code path that writes to remember every affected key doesn't scale: someone adds a write path and forgets. The robust design is to tail the database's change log (MySQL binlog, PostgreSQL WAL logical replication, DynamoDB Streams) and turn each committed change into deletes of the keys it affects.

App server writes, commits Database source of truth Change log binlog / WAL Invalidator CDC consumer Cache region 1 Cache region 2 DEL fast path: DEL in its own region, for read-your-writes every committed change becomes a DEL, and can be replayed from the log TTLs catch whatever is still missed
Deriving deletes from the commit log means no write path can forget to invalidate.

This is what Facebook described in "Scaling Memcache at Facebook" (NSDI 2013): a daemon called mcsqueal runs on every database, reads its MySQL commit log and broadcasts the deletes to every frontend memcache cluster in that region (other regions get the change through MySQL replication, and their own databases' mcsqueal invalidates their clusters), while the web server that made the change also deletes in its own cluster so the user sees their own write. Because the invalidations are derived from what the database committed and kept in its log, a misrouted or lost batch can be replayed, which was hard when web servers sent deletes directly. The paper also notes that only about 4% of deletes they issue actually remove a cached item, which tells you invalidation traffic is mostly wasted work, so it's batched. Today you'd build the same thing with Debezium (or a managed CDC stream) feeding a small consumer.

The price of CDC: one more pipeline to run and monitor, invalidations arrive with the pipeline's lag (typically well under a second, but it can grow), and the consumer must map a row change to every cache key derived from that row, which is easiest when keys are named after rows (product:42) rather than after queries.

AdvancedReplicas make it worse

With read replicas, "delete then the next read reloads" has a second race: the next read may go to a replica that hasn't applied the write yet, and it caches the old row with a fresh TTL. Fixes: read from the primary when refilling a key that was just invalidated, delay the invalidation until replicas have caught up (CDC from the replica's log does this naturally), or mark recently written keys so their refills go to the primary. Facebook calls the last one a remote marker: when a non-primary region writes, it sets a marker for the key, and a reader that sees the marker sends its refill query to the primary region.

AdvancedVersioned keys

Some invalidations would have to delete many keys at once: everything rendered for one user, every page of a category listing, every cached query for one tenant. A cache has no "delete by prefix" that's cheap; in Redis you'd have to SCAN the whole keyspace. Versioned keys turn that into a single write: keep a small version number per user (or tenant, or category), put it in every derived key's name, and bump it to invalidate them all.

versioned-keys.mjs
// A fake shared cache that counts how many keys each operation has to touch.
const cache = new Map();
let touched = 0;
const get = (k) => { touched++; return cache.get(k); };
const set = (k, v) => { touched++; cache.set(k, v); };

// 100,000 cached fragments from 2,000 users; user 42 owns 50 of them.
for (let u = 0; u < 2_000; u++) for (let f = 0; f < 50; f++) cache.set(`u:${u}:v1:frag:${f}`, `html ${u}/${f}`);
for (let u = 0; u < 2_000; u++) cache.set(`u:${u}:ver`, 1);   // each user's current namespace version

// Option A: delete by pattern. The cache has no index by prefix, so you scan everything.
function deleteByPattern(prefix) {
  let deleted = 0;
  for (const k of [...cache.keys()]) { touched++; if (k.startsWith(prefix) && !k.endsWith(":ver")) { cache.delete(k); deleted++; } }
  return deleted;
}
// Option B: bump the user's version. Every key the app builds now has a new name.
const bump = (u) => set(`u:${u}:ver`, get(`u:${u}:ver`) + 1);
const keyFor = (u, f) => `u:${u}:v${get(`u:${u}:ver`)}:frag:${f}`;

touched = 0;
const deleted = deleteByPattern("u:42:");
console.log(`A  delete by pattern: removed ${deleted} keys, touched ${touched} keys`);

for (let f = 0; f < 50; f++) cache.set(`u:42:v1:frag:${f}`, `html 42/${f}`);   // put them back for option B
touched = 0;
bump(42);
console.log(`B  bump version:      touched ${touched} keys, version is now ${cache.get("u:42:ver")}`);
console.log(`   next read builds ${keyFor(42, 7)} -> ${cache.get(keyFor(42, 7)) ?? "miss, reload from DB"}`);
const orphans = [...cache.keys()].filter((k) => k.startsWith("u:42:v1:")).length;
console.log(`   ${orphans} old v1 keys are now unreachable; their TTL (or eviction) removes them`);
Result
A  delete by pattern: removed 50 keys, touched 102000 keys
B  bump version:      touched 2 keys, version is now 2
   next read builds u:42:v2:frag:7 -> miss, reload from DB
   50 old v1 keys are now unreachable; their TTL (or eviction) removes them
What’s happening
  1. The fake cache counts every key an operation touches, which is the cost that matters on a real cache server. It holds 100,000 fragments for 2,000 users, plus one u:<id>:ver key per user holding their current namespace version, 1.
  2. Option A deletes user 42's 50 fragments by pattern. There's no index by prefix, so it has to walk every key: 102,000 keys touched to remove 50. In Redis that's a SCAN across the keyspace, slow on a big instance and slower across a cluster, where every node must be scanned.
  3. Option B bumps u:42:ver from 1 to 2: a read and a write, 2 keys touched. (In Redis, a single INCR.)
  4. Every key the app builds for user 42 now contains v2, so the next read of fragment 7 looks up u:42:v2:frag:7, misses, and reloads from the database. Nothing had to be found or deleted.
  5. The 50 old v1 entries are still in memory but unreachable; their TTL (or LRU eviction, since nobody reads them) clears them. That's the cost: memory held by garbage until it expires, plus one extra lookup per request to learn the current version (cheap if fetched together with the data, or cached in-process for a second).

Versioning by content is the same idea at the edge: app.3f9a1c.js is a versioned key. A deploy changes the hash, the HTML points at the new name, and the old file is never requested again, which is the only reliable way to "invalidate" a browser cache you can't reach.

Delete first, then write?

A colleague changes the write path to "delete the cache key, then update the database", reasoning that the cache is empty while the write happens, so nobody can read a stale value. What goes wrong?

Show answer

A reader can refill the cache with the old value between the delete and the commit, and it stays there until the TTL.

What’s happening
  1. The writer deletes product:42. The cache is now empty for that key, as intended.
  2. Before the writer's UPDATE commits (a transaction can take tens of milliseconds, longer under lock contention), a reader misses the cache and reads the row from the database, which still holds the old price.
  3. The reader sets the cache to the old price with a fresh TTL.
  4. The writer's transaction commits the new price. Nothing deletes the key again, so the cache serves the old price until the TTL expires.
  5. Compared with update-then-delete, the window is far wider: it's the whole write transaction, not a rare reader stall. That's why the rule is commit first, then delete (and, if you're worried, delete once more a moment later).

#Hot keys and cache stampedes

Added

Two failures come from the same root: traffic is not spread evenly over keys. A hot key is one key that gets so many requests that the single cache node holding it runs out of CPU or network, even though the cluster as a whole is idle; a viral post, a celebrity profile, a flash-sale product or a global config key. A cache stampede (also thundering herd or dog-piling) is what happens when a popular key disappears, through expiry, eviction, invalidation or a cache restart: every request that arrives before it's refilled misses, and each one goes to the database for the same row. The database, which the cache was protecting, gets the full traffic at once, slows down, so the refill takes longer, so even more requests pile in.

The mental model: a hot key is a supermarket where one checkout has a celebrity behind the till: every other lane is empty, and that one has a queue out of the door. A stampede is the doors opening at 9:00 on Black Friday: the crowd was held back (by the cache), and the instant the barrier lifts, everyone rushes the same shelf. The fixes follow the picture: open more lanes for the celebrity (copies of the hot key), and let one person fetch the item while the crowd waits or, better, give them yesterday's item while it's restocked.

The Node.js course shows request coalescing inside one process (1,000 concurrent misses become one database read) and the Databases course measures a cross-instance lock against real Redis and MySQL. Here's the design question those leave open: with many app servers, which defence actually protects the database, and what does each cost the users who arrive during the refill?

The model: 20 app servers each receive 50 requests a second for one hot key (1,000 a second in total, each server's arrivals offset by a seeded random amount). The key expires at t = 0. A database query takes 100 ms, plus 10 ms for every query already running, which is a simple way to model a database slowing down under concurrent load. It's virtual time, so the output is exact and repeatable.

stampede.mjs
function mulberry32(a) {                                    // seeded PRNG: same numbers every run
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const SERVERS = 20, EVERY = 20, POLL = 50;                    // 20 app servers x 50 req/s = 1,000 req/s on one key
const rand = mulberry32(9);
const arrivals = [];
for (let s = 0; s < SERVERS; s++) {
  const offset = Math.floor(rand() * EVERY);
  for (let t = offset; t < 1_000; t += EVERY) arrivals.push({ t, s });
}
arrivals.sort((a, b) => a.t - b.t || a.s - b.s);

function run(strategy) {
  let doneAt = Infinity, queries = 0, inFlight = [], waited = 0, waitSum = 0, maxWait = 0;
  const perServer = new Map();                                // server -> time its own query finishes
  const query = (now) => {                                    // the DB slows down as concurrent queries pile up
    inFlight = inFlight.filter((end) => end > now);
    const end = now + 100 + 10 * inFlight.length;
    inFlight.push(end); queries++; doneAt = Math.min(doneAt, end);
    return end;
  };
  const wait = (ms) => { waited++; waitSum += ms; maxWait = Math.max(maxWait, ms); };
  for (const { t, s } of arrivals) {                          // the key expired at t = 0
    if (t >= doneAt) continue;                                // a refill has landed: plain hit
    if (strategy === "no protection") wait(query(t) - t);
    if (strategy === "coalesce per server") {
      if (!perServer.has(s)) perServer.set(s, query(t));
      wait(perServer.get(s) - t);
    }
    if (strategy === "distributed lock") {                    // one lock holder; the rest poll every 50 ms
      if (queries === 0) wait(query(t) - t);
      else wait(Math.ceil((doneAt - t) / POLL) * POLL);
    }
    if (strategy === "serve stale + 1 refresh" && queries === 0) query(t);  // nobody waits
  }
  const avg = waited ? (waitSum / waited).toFixed(0) : 0;
  console.log(`${strategy.padEnd(24)} db queries ${String(queries).padStart(3)}  requests that waited ${String(waited).padStart(3)}` +
    `  avg wait ${String(avg).padStart(3)} ms  max wait ${String(maxWait).padStart(3)} ms`);
}
for (const s of ["no protection", "coalesce per server", "distributed lock", "serve stale + 1 refresh"]) run(s);
Result
no protection            db queries 100  requests that waited 100  avg wait 595 ms  max wait 1090 ms
coalesce per server      db queries  20  requests that waited 100  avg wait 155 ms  max wait 290 ms
distributed lock         db queries   1  requests that waited 100  avg wait  75 ms  max wait 100 ms
serve stale + 1 refresh  db queries   1  requests that waited   0  avg wait   0 ms  max wait   0 ms
What’s happening
  1. Every request that arrives before the first refill lands (t < doneAt) needs the value; after that, everyone hits. So the race is against doneAt, and each strategy decides how many queries start before it and how long people wait.
  2. No protection: every one of the first 100 requests (100 ms of traffic at 1,000/s) starts its own query. The database slows as they pile up: the 100th query takes 100 + 99 × 10 = 1,090 ms, so the average wait is 595 ms and the worst over a second. In a real outage this is where the feedback loop starts, because slow queries hold connections and the pile grows.
  3. Coalescing per server (single-flight inside each process) stops duplicate queries within a server, but each of the 20 servers still sends one: 20 queries. That's a 5× improvement, and waits fall to 155 ms on average, but the database load grows with the number of app servers, which autoscaling increases exactly when traffic is high.
  4. A distributed lock (or a lease) lets exactly one request across all servers query; everyone else polls every 50 ms. 1 query, and waits of 75 ms on average, 100 ms at most, because the polling interval rounds each wait up to the next 50 ms step.
  5. Serve stale + 1 refresh: the value is past its soft expiry but still in the cache, so every request gets it immediately, and only the first one (holding the lock) refreshes it in the background. 1 query and nobody waits. The cost is that for about 100 ms users see a value that's slightly past its soft TTL, which for a hot key is almost always acceptable.
  6. Reading the table as a design: coalescing is free and always worth doing, but on its own it scales with the fleet. Protecting the database needs one refresher across the fleet (lock or lease), and protecting users' latency needs a stale copy to serve while it refreshes.
AdvancedSoft and hard TTLs

The last strategy needs the cache entry to outlive its freshness, which is the soft TTL / hard TTL split: store the value with a long real expiry and, inside the value, a shorter "refresh after" time.

fresh serve from the cache stale but usable serve it; one request refreshes gone wait for one load loaded soft TTL (e.g. 60 s) hard TTL (e.g. 10 min) time a hot key is refreshed long before its hard TTL, so users never wait for it
Two expiry times per value: the soft one triggers a refresh, the hard one is the real eviction.
AdvancedFacebook's leases, in numbers

"Scaling Memcache at Facebook" (NSDI 2013) describes the lock-like defence built into the cache itself. On a miss, memcached gives the client a lease token, and it hands out a token for a given key at most once every 10 seconds by default. Anyone else who misses within those 10 seconds is told to wait a short time and retry; by then the lease holder has usually set the value within a few milliseconds. On a week of misses for keys prone to thundering herds, the paper reports the peak database query rate fell from 17K/s without leases to 1.3K/s with them, and since they provision databases for peak load, that's a direct hardware saving. Clients that can live with slightly old data can instead take a stale value that memcached keeps briefly after a delete, which is the "serve stale" row of the model.

AdvancedSynchronised expiry and TTL jitter

Stampedes don't need one hot key. If many keys are written at the same moment (a cache restart, a deploy that warms the cache, a nightly job), they all get the same TTL and expire at the same moment, and because each is reloaded as soon as it expires, they get the same TTL again. The wave repeats every TTL period. The model below loads 100,000 keys in the first 10 seconds after a restart, gives each a 300 s TTL, and assumes every key is read constantly, so it's reloaded the second it expires. It counts database reloads per second over an hour, with and without random jitter on each TTL.

ttl-jitter.mjs
function mulberry32(a) {                                    // seeded PRNG: same numbers every run
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
// 100,000 keys are all loaded in the first 10 s after a cache restart, each with a 300 s TTL.
// Every key is read constantly, so it is reloaded from the database the second it expires.
const KEYS = 100_000, TTL = 300, HOURS = 1;

function run(jitter) {
  const rand = mulberry32(5);
  const reloadsPerSecond = new Array(HOURS * 3600).fill(0);
  for (let k = 0; k < KEYS; k++) {
    let t = Math.floor(rand() * 10);                          // first load during the warm-up burst
    while (t < reloadsPerSecond.length) {
      reloadsPerSecond[t]++;
      const ttl = TTL * (1 + jitter * (2 * rand() - 1));      // jitter = 0.1 means 270..330 s
      t += Math.max(1, Math.round(ttl));
    }
  }
  const peakIn = (from, to) => Math.max(...reloadsPerSecond.slice(from, to));
  const waves = [0, 1, 2, 5, 11].map((w) => String(peakIn(w * TTL, (w + 1) * TTL)).padStart(6));
  console.log(`jitter ±${String(jitter * 100).padStart(2)}%  peak DB reloads/s in TTL periods 1,2,3,6,12: ${waves.join(" ")}`);
}
console.log(`average if spread evenly: ${Math.round(KEYS / TTL)} reloads/s`);
for (const j of [0, 0.1, 0.25]) run(j);
Result
average if spread evenly: 333 reloads/s
jitter ± 0%  peak DB reloads/s in TTL periods 1,2,3,6,12:  10167  10167  10167  10167  10167
jitter ±10%  peak DB reloads/s in TTL periods 1,2,3,6,12:  10125   1753   1611   1028    720
jitter ±25%  peak DB reloads/s in TTL periods 1,2,3,6,12:  10073    706    663    450    396
What’s happening
  1. Without jitter, every key's reload times are its first load time plus multiples of exactly 300 s. 100,000 keys first loaded over 10 seconds means about 10,000 a second, and that 10,167/s peak comes back in every period: the 2nd, 3rd, 6th, 12th. The burst never smooths out on its own.
  2. Spread evenly, the same work would be 100,000 / 300 = 333 reloads a second. The wave asks the database for about 30× its average load, every five minutes.
  3. With ±10% jitter, each reload draws a TTL between 270 and 330 s. The first period is the warm-up itself (10,125/s), but the second is already down to 1,753/s and by the 12th to 720/s: each period the random TTLs add up and the keys drift apart.
  4. ±25% jitter smooths faster (706/s in the second period, 396/s by the 12th) at the cost of a less predictable freshness bound: a key may now live 375 s.
  5. Jitter doesn't fix the first wave (the warm-up burst), which needs admission control or warming the cache gradually. What it fixes is the echo, which otherwise hits the database every TTL for as long as the system runs.
AdvancedHot keys: copies, not more shards

A hot key is a different problem: the value is in the cache, but one node has to serve all its traffic. Adding shards doesn't help, because a key lives on exactly one shard however many there are. The model puts 500,000 reads a second on a 16-shard cache: 80% follow a Zipf distribution over a million keys, and 20% are for one viral post. It reports the busiest shard's load and how far above the average shard it is, then tries two fixes.

hot-keys.mjs
const SHARDS = 16, KEYS = 1_000_000, RPS = 500_000, APP_SERVERS = 50;
const shardOf = (key) => {                                    // murmur3's fmix32 as a cheap, well-mixed hash
  let h = key ^ 0x9e3779b9;
  h = Math.imul(h ^ (h >>> 16), 0x85ebca6b); h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
  return ((h ^ (h >>> 16)) >>> 0) % SHARDS;
};
let norm = 0;
for (let i = 1; i <= KEYS; i++) norm += 1 / i;                // Zipf, s = 1: key 0 is the most popular
const zipfRate = (key) => (RPS * 0.8) / (key + 1) / norm;     // 80% of traffic follows Zipf ...
const viral = RPS * 0.2;                                      // ... and 20% is one viral post, key 0

function report(label, loadOfKey) {
  const shards = new Array(SHARDS).fill(0);
  for (let k = 0; k < KEYS; k++) for (const [shard, rps] of loadOfKey(k)) shards[shard] += rps;
  const max = Math.max(...shards), mean = shards.reduce((a, b) => a + b) / SHARDS;
  const total = shards.reduce((a, b) => a + b);
  console.log(`${label.padEnd(30)} busiest shard ${String(Math.round(max)).padStart(6)}/s  ` +
    `${(max / mean).toFixed(2)}x the mean   cache traffic ${Math.round(total)}/s`);
}
const rate = (k) => zipfRate(k) + (k === 0 ? viral : 0);

report("no viral post", (k) => [[shardOf(k), zipfRate(k)]]);
report("viral post", (k) => [[shardOf(k), rate(k)]]);
// L1: every app server keeps the top 100 keys in memory for 1 s, so it asks the shard once a second per key.
report("viral + L1 for top 100 (1 s)", (k) => [[shardOf(k), k < 100 ? Math.min(rate(k), APP_SERVERS) : rate(k)]]);
// Split: the viral key is written to 8 copies, "post:0#0".."post:0#7"; each read picks one copy at random.
report("viral + split into 8 copies", (k) =>
  k === 0 ? Array.from({ length: 8 }, (_, i) => [shardOf(KEYS + i), rate(0) / 8]) : [[shardOf(k), rate(k)]]);
Result
no viral post                  busiest shard  50774/s  2.03x the mean   cache traffic 400000/s
viral post                     busiest shard 150774/s  4.82x the mean   cache traffic 500000/s
viral + L1 for top 100 (1 s)   busiest shard  17131/s  1.05x the mean   cache traffic 260833/s
viral + split into 8 copies    busiest shard  68659/s  2.20x the mean   cache traffic 500000/s
What’s happening
  1. The rates are computed exactly from the Zipf formula (no sampling), and each key's shard comes from a well-mixed integer hash (fmix32 from MurmurHash3) mod 16. Even without the viral post, the busiest shard carries 2.03× the mean, because the most popular Zipf key alone gets about 28,000 reads a second and lands on one shard.
  2. The viral post adds 100,000 reads a second to one key, so its shard carries 150,774/s, 4.82× the mean. If a cache node handles roughly 100,000 operations a second (a rule of thumb; measure your own), that shard is overloaded while the other 15 average about 23,000/s.
  3. An L1 cache on every app server for the top 100 keys, with a 1-second TTL: each of the 50 app servers asks the shard for each hot key at most once a second, so a hot key costs its shard at most 50 reads a second however popular it is. The busiest shard drops to 17,131/s (1.05×), and total cache traffic falls from 500,000/s to 260,833/s. The cost is up to a second of staleness on those keys and a little memory on each server.
  4. Splitting the key into 8 copies (post:0#0 to post:0#7, each read picks one at random) divides the viral load by 8, but the busiest shard is still 2.20× the mean: copies land wherever their names hash (two can share a shard), and the ordinary Zipf head is still uneven. Every update must also write all 8 copies.
  5. So for read-hot keys the strongest fix is in-process caching of the few hottest keys with a very short TTL, which takes the load off the cache tier entirely. Splitting is the tool when an L1 isn't possible (many different clients, or the value must be fresher than a second), and for write-hot keys such as a counter, where the split copies are summed on read.

Finding hot keys is half the job. Options: sample commands at the client and count keys (cheap, works on any cache); Redis's redis-cli --hotkeys, which needs an LFU eviction policy because it reads the LFU counters (on an LRU policy Redis 8.10 refuses with "ERR An LFU maxmemory policy is not selected, access frequency not tracked", checked in a local container); per-node CPU and network metrics, where one node far above the rest is the signature of a hot key; and, best of all, knowing your domain: a celebrity's post, the home page, the product in tonight's TV advert.

Twenty servers, one key

Twenty app servers each coalesce misses in-process (single-flight). A hot key expires, and requests arrive at every server before the first database query has returned. How many database queries run? What changes if autoscaling has just doubled the fleet?

Show answer

20 queries, one per server; after the fleet doubles, 40.

What’s happening
  1. Single-flight keeps a map of in-flight loads inside one process. The first request on server 1 starts a query and stores its promise; the other requests on server 1 await that promise. Server 1 sends one query.
  2. Server 2 has its own map and knows nothing about server 1's query, so its first request starts another one. The same happens on all 20 servers: 20 identical queries, as the stampede model measured.
  3. Doubling the fleet doubles the duplicate queries to 40, and autoscaling adds servers exactly when traffic (and therefore the stampede) is biggest.
  4. To make it one query regardless of fleet size, the "only one loader" decision has to live somewhere shared: a lock or lease in the cache, or a single background refresher. Serving the stale value meanwhile removes the wait as well.

#Consistent hashing

Added

A distributed cache has to decide which node holds which key, and every client must reach the same answer without asking anyone. The obvious rule, node = hash(key) % N, works perfectly until N changes. Add an eleventh node to ten and % 11 gives a different answer from % 10 for almost every key: about 10 keys in 11 suddenly live somewhere else. For a database that's a massive data migration; for a cache it's worse, because those keys aren't moved, they're lost: the cache's hit ratio collapses to near zero and the whole read load falls on the database at once.

Consistent hashing (Karger et al., 1997) is a placement rule where adding or removing a node moves only the keys that have to move, about 1/N of them. Hash every node to a position on a circle (the hash ring, positions 0 to 2³² − 1, wrapping around), hash every key onto the same circle, and give each key to the first node clockwise from it. When a node joins, it takes over only the arc between itself and its predecessor; every other key stays where it was. When a node leaves, only its own keys move, to its clockwise neighbour.

The mental model: a round table with people seated around it, and letters dropped on the tabletop. Each letter belongs to the next person sitting clockwise from where it landed. A newcomer pulls up a chair between two people and takes only the letters between their chair and the previous person's. Nobody else at the table is disturbed. Virtual nodes are the same people each sitting in several chairs around the table, so everyone's letters come from many small stretches rather than one big one.

One point per node: D joins Four virtual nodes per node A B C D k1 k2 k3 k4 k5 k6 A1 A2 A3 A4 B1 B2 B3 B4 C1 C2 C3 C4 keys go clockwise to the next node D takes the highlighted arc: only k2 moves, from B many small arcs per node: even load, and a joining node takes a little from every node
Only the keys between a new node and its predecessor change owner. Virtual nodes cut each node's share into many small arcs.

The model measures it: 100,000 keys on 10 cache nodes, then a node joins (10 → 11) or leaves (10 → 9). For each placement scheme it reports the share of keys whose node changed, how many existing nodes those moves touched, and how even the load was before the change (the busiest and emptiest node relative to the mean). Node and key positions come from MD5 (via node:crypto), so the output is deterministic.

ring.mjs
import { createHash } from "node:crypto";
const hash32 = (s) => createHash("md5").update(s).digest().readUInt32BE(0);   // a point on a 0..2^32 ring

class Ring {
  constructor(nodes, vnodes) {
    this.points = [];                                        // sorted [position, node]
    for (const n of nodes) for (let v = 0; v < vnodes; v++) this.points.push([hash32(`${n}#${v}`), n]);
    this.points.sort((a, b) => a[0] - b[0]);
  }
  nodeFor(key) {                                             // first point clockwise from the key's position
    const h = hash32(key);
    let lo = 0, hi = this.points.length;
    while (lo < hi) { const mid = (lo + hi) >> 1; if (this.points[mid][0] < h) lo = mid + 1; else hi = mid; }
    return this.points[lo % this.points.length][1];          // past the last point, wrap to the first
  }
}
const modulo = (nodes) => ({ nodeFor: (key) => nodes[hash32(key) % nodes.length] });

const keys = Array.from({ length: 100_000 }, (_, i) => `user:${i}`);
const nodes = (n) => Array.from({ length: n }, (_, i) => `cache-${i}`);
function compare(label, before, after, side = "from") {   // "from": who gave keys up, "to": who took them
  let moved = 0;
  const load = new Map(), losers = new Set();
  for (const k of keys) {
    const a = before.nodeFor(k), b = after.nodeFor(k);
    if (a !== b) { moved++; losers.add(side === "from" ? a : b); }
    load.set(a, (load.get(a) ?? 0) + 1);
  }
  const counts = [...load.values()], mean = keys.length / counts.length;
  console.log(`${label.padEnd(26)} moved ${((moved / keys.length) * 100).toFixed(1).padStart(4)}% (${side} ${String(losers.size).padStart(2)} node${losers.size === 1 ? " " : "s"})  ` +
    `before: busiest ${(Math.max(...counts) / mean).toFixed(2)}x, emptiest ${(Math.min(...counts) / mean).toFixed(2)}x the mean`);
}
console.log("10 -> 11 nodes (one joins); the minimum possible move is 1/11 = 9.1%");
compare("  hash % N", modulo(nodes(10)), modulo(nodes(11)));
for (const v of [1, 10, 100, 1000]) compare(`  ring, ${v} vnode${v === 1 ? "" : "s"}/node`, new Ring(nodes(10), v), new Ring(nodes(11), v));
console.log("10 -> 9 nodes (cache-3 leaves)");
const nine = nodes(10).filter((n) => n !== "cache-3");
compare("  hash % N", modulo(nodes(10)), modulo(nine), "to");
compare("  ring, 1 vnode/node", new Ring(nodes(10), 1), new Ring(nine, 1), "to");
compare("  ring, 100 vnodes/node", new Ring(nodes(10), 100), new Ring(nine, 100), "to");
Result
10 -> 11 nodes (one joins); the minimum possible move is 1/11 = 9.1%
  hash % N                 moved 91.0% (from 10 nodes)  before: busiest 1.01x, emptiest 0.99x the mean
  ring, 1 vnode/node       moved  7.9% (from  1 node )  before: busiest 2.30x, emptiest 0.13x the mean
  ring, 10 vnodes/node     moved  5.7% (from  6 nodes)  before: busiest 1.47x, emptiest 0.67x the mean
  ring, 100 vnodes/node    moved 10.6% (from 10 nodes)  before: busiest 1.07x, emptiest 0.89x the mean
  ring, 1000 vnodes/node   moved  8.9% (from 10 nodes)  before: busiest 1.05x, emptiest 0.95x the mean
10 -> 9 nodes (cache-3 leaves)
  hash % N                 moved 89.9% (to  9 nodes)  before: busiest 1.01x, emptiest 0.99x the mean
  ring, 1 vnode/node       moved 14.4% (to  1 node )  before: busiest 2.30x, emptiest 0.13x the mean
  ring, 100 vnodes/node    moved  9.7% (to  9 nodes)  before: busiest 1.07x, emptiest 0.89x the mean
What’s happening
  1. Ring puts vnodes points per node on the ring, at hash32("cache-3#0"), hash32("cache-3#1") and so on, and sorts them. nodeFor hashes the key and binary-searches for the first point at or after it; past the last point it wraps to the first, which is what makes it a circle.
  2. hash % N spreads load perfectly (busiest 1.01×) but moves 91.0% of keys when a node joins and 89.9% when one leaves. For a cache, that's 9 keys in 10 becoming misses at the same moment, a self-inflicted stampede (see Distributed caches for what it does to the database).
  3. The ring with one point per node moves only 7.9%: just the arc the new node landed in. But the arcs are random lengths, so the load is wildly uneven (the busiest node holds 2.30× the mean, the emptiest 0.13×), and the moved keys come from one node only, so one neighbour does all the work while the newcomer gets whatever arc chance gave it (7.9%, not the fair 9.1%). When cache-3 leaves, all its 14.4% go to one neighbour, which then holds its own keys plus those.
  4. Virtual nodes fix both problems. With 100 points per node, each node owns 100 small arcs, so the load evens out to 1.07× / 0.89× (1,000 points give 1.05× / 0.95×), and a joining node takes small slices from all 10 nodes, while a leaving node's keys spread over all 9 survivors. The share moved stays close to the ideal: 10.6% and 8.9% against the minimum possible 1/11 = 9.1%, and 9.7% when cache-3 (which held about 10%) leaves.
  5. The cost of virtual nodes is memory and lookup time for the ring (10 nodes × 1,000 points is 10,000 entries, still tiny), and more bookkeeping when data has to be streamed between nodes, which is why stores tune the number rather than maximising it.
  6. If you halved the keys or doubled them, the percentages would barely change: consistent hashing's guarantee is about shares, not counts. What changes the result is the number of points per node, which controls how close each node's share is to 1/N.
AdvancedAlternatives you should be able to name

The ring isn't the only scheme with the "move only 1/N" property, and two others are simpler.

Rendezvous hashing (highest random weight, Thaler and Ravishankar, 1996): for each key, score every node with hash(node, key) and pick the highest score. When a node joins, it wins only the keys where its score beats the old winner's, about 1/(N + 1) of them; when one leaves, only its keys move, each to its runner-up. No ring, no virtual nodes, perfectly even load, but each lookup costs N hashes, which is fine for tens of nodes and poor for thousands.

Jump consistent hash (Lamping and Veach, Google, 2014) computes the bucket for a key from the key and the bucket count alone, in a few lines of arithmetic and no memory at all. Its catch: buckets must be numbered 0..n-1, so you can only add or remove the last bucket. That suits storage shards that are renamed and replicated behind the number, not caches whose arbitrary members come and go.

hrw-jump.mjs
import { createHash } from "node:crypto";
const md5 = (s) => createHash("md5").update(s).digest();

// Rendezvous (highest random weight): score every node for this key, pick the highest.
const rendezvous = (key, nodes) => {
  let best, bestScore = -1;
  for (const n of nodes) { const s = md5(`${n}|${key}`).readUInt32BE(0); if (s > bestScore) [best, bestScore] = [n, s]; }
  return best;
};
// Jump consistent hash (Lamping and Veach, 2014): no table at all, buckets must be numbered 0..n-1.
const jump = (key, buckets) => {
  let k = md5(key).readBigUInt64BE(0), b = -1n, j = 0n;
  while (j < BigInt(buckets)) {
    b = j;
    k = (k * 2862933555777941757n + 1n) & 0xffffffffffffffffn;   // 64-bit linear congruential step
    j = BigInt(Math.floor(Number(b + 1n) * (2 ** 31 / Number((k >> 33n) + 1n))));
  }
  return Number(b);
};

const keys = Array.from({ length: 100_000 }, (_, i) => `user:${i}`);
const names = (n) => Array.from({ length: n }, (_, i) => `cache-${i}`);
for (const [label, place] of [
  ["rendezvous", (k, n) => rendezvous(k, names(n))],
  ["jump hash", (k, n) => jump(k, n)],
]) {
  let moved = 0;
  const load = new Map();
  for (const k of keys) {
    const a = place(k, 10);
    if (a !== place(k, 11)) moved++;
    load.set(a, (load.get(a) ?? 0) + 1);
  }
  const c = [...load.values()], mean = keys.length / 10;
  console.log(`${label.padEnd(10)} 10 -> 11 nodes: moved ${((moved / keys.length) * 100).toFixed(1)}%  ` +
    `busiest ${(Math.max(...c) / mean).toFixed(2)}x, emptiest ${(Math.min(...c) / mean).toFixed(2)}x the mean`);
}
Result
rendezvous 10 -> 11 nodes: moved 9.1%  busiest 1.01x, emptiest 0.99x the mean
jump hash  10 -> 11 nodes: moved 9.3%  busiest 1.02x, emptiest 0.98x the mean
What’s happening
  1. Rendezvous: md5("cache-3|user:17") gives node 3's score for that key; the highest of the 10 scores wins. Adding cache-10 adds one more score per key, and the key moves only if the new score is the highest: 9.1% of keys, exactly 1/11, and the load is even (1.01× / 0.99×) because each key's winner is an independent random draw.
  2. Jump hash turns the key into a 64-bit seed and repeatedly "jumps" forward: each step uses a 64-bit linear congruential generator (BigInt here, because JavaScript numbers can't hold 64-bit integers exactly) to decide how far the key's bucket could jump as buckets are added, and stops when the next jump would land past buckets. Going from 10 to 11 buckets moves 9.3% of keys, all of them into the new bucket 10.
  3. Both are checked against the property that matters: every key that moved, moved to the new node (a separate check over 20,000 keys found no exceptions), so no key shuffles between old nodes.
  4. In an interview, the ring with virtual nodes is the answer people expect; mentioning rendezvous hashing (simpler, even, O(N) per lookup) and jump hash (no memory, numbered buckets only) shows you know the design space.
AdvancedWho uses what
  • Memcached clients use consistent hashing on the client side (the "ketama" ring is the classic implementation); the servers don't know about each other. Facebook's memcache paper says items are distributed across hundreds of servers in a cluster this way.
  • Cassandra and Dynamo-style stores place nodes on a token ring with virtual nodes. Cassandra 5.0's default is num_tokens: 16 (its docs, checked October 2026); older versions defaulted to 256, and a newer token allocator chooses positions to keep load even with fewer tokens.
  • Redis Cluster doesn't use a ring. It uses a fixed set of 16,384 hash slots, slot = CRC16(key) mod 16384, and an explicit table saying which node owns which slots. Adding a node means moving some slots to it, deliberately, with live migration. It's the same goal (only the moved slots' keys change owner) reached with a directory instead of a ring, and it lets an operator move exactly the slots they choose (see Distributed caches).

Fixed slots versus a ring is a real design choice. A ring needs no coordination (every client computes the same answer from the member list) but moves whatever arcs the hashes dictate. Slots need a shared slot table that every client must learn and refresh, but rebalancing is explicit, can be done slot by slot at a chosen pace, and lets you place hot slots on bigger machines.

How much moves?

A cache cluster has 4 nodes and 1,000,000 keys, placed with a consistent-hashing ring with plenty of virtual nodes. You add a fifth node. Roughly how many keys change node, and where do they come from? How many would move with hash % N?

Show answer

About 200,000 keys move (1/5), taken roughly equally from all four old nodes; with hash % N, about 800,000 would move.

What’s happening
  1. After the change each of the 5 nodes should own about 1/5 of the ring. The new node starts with nothing, so it must take 1/5 of the keys: 1,000,000 / 5 = 200,000. No other key needs to move, and on a ring none does.
  2. With many virtual nodes, the new node's points are scattered around the ring, and each one takes a small arc from whichever old node owned it. So each old node gives up about a fifth of its keys, about 50,000 each, rather than one neighbour losing 200,000.
  3. With hash % N, a key stays put only if h % 4 === h % 5. That happens when h mod 20 is 0, 1, 2 or 3: 4 residues out of 20, so 1/5 of keys stay and 4/5 (800,000) move. In general going from N to N + 1 keeps only about 1/(N + 1) of the keys, which matches the model's 91% for 10 → 11.
  4. For a cache, "move" means "miss": the ring costs a 20% dip in hit ratio that refills quickly; the modulo scheme costs an 80% one, which is a stampede on the database.

#Distributed caches

Added

One Redis or Memcached server is fast, but it's one machine: its memory caps how much you can cache, its CPU and network cap how many operations a second it serves, and when it dies or restarts the whole cache is gone at once. A distributed cache spreads keys over many nodes (sharding, placed with consistent hashing or hash slots) and optionally keeps a copy of each shard on another node (replication). The design questions are: who knows which node holds a key, what happens to reads and writes when a node fails, and can the database survive the misses while the cache recovers?

There are three ways to answer "who knows where a key lives":

  • Client-side sharding (classic Memcached): the servers are independent and don't know about each other. Every client has the server list and hashes keys onto it, usually with a consistent-hashing ring. Simple and fast, but every client must agree on the list, and nothing replicates.
  • Server-side cluster (Redis Cluster, Valkey cluster): the nodes share a slot table and gossip about each other's health. Clients cache the table and send each command to the right node; a node that receives a key it doesn't own replies with a redirect. Replicas take over automatically when a primary fails.
  • A proxy (mcrouter at Facebook, twemproxy, Envoy's Redis proxy): clients talk to a local proxy that knows the topology, routes, replicates writes to pools, and fails over. Clients stay simple; the proxy is one more hop and one more thing to run.

The mental model: a city's library branches. With client-side sharding, every reader carries a printed map of which branch holds which shelf; if a branch closes, readers with an old map go to the wrong place, and the books in it are simply unavailable. A server-side cluster is branches that know each other: go to the wrong one and the librarian tells you exactly where to go, and every branch has a twin across town holding a copy of its books, which opens its doors if the original burns down.

App servers cluster client caches the slot map CRC16(key) % 16384 Primary A slots 0–5460 Primary B slots 5461–10922 Primary C slots 10923–16383 Replica A another zone Replica B another zone Replica C another zone async async async wrong node? it answers MOVED (or ASK during a migration) and the client updates its map primary fails? its replica is promoted after the node timeout plus a second or two
A Redis Cluster: slots are split across primaries, each replicated asynchronously to another zone.

The question interviewers push on is "what happens when a cache node dies?", and the honest answer is "it depends on how the database copes with the misses". The estimate below puts numbers on it for a read-heavy service: 200,000 reads a second, a 95% hit ratio, 10 cache nodes, and a database that can serve about 25,000 reads a second before its latency climbs (all assumptions, stated so you can change them). A node holds 1/10 of the keys; while they're missing, every read for them goes to the database.

node-failure.mjs
// Assumptions (stated in the prose): a read-heavy service behind a 10-node cache cluster.
const readsPerSec = 200_000;
const hitRatio = 0.95;            // steady state
const cacheNodes = 10;
const dbCapacity = 25_000;        // reads/s the database can serve before latency climbs

const dbLoad = (missFraction) => Math.round(readsPerSec * missFraction);
const show = (label, missFraction) => {
  const load = dbLoad(missFraction);
  const verdict = load <= dbCapacity ? "ok" : `OVERLOADED ${(load / dbCapacity).toFixed(1)}x`;
  console.log(`${label.padEnd(48)} db ${String(load).padStart(6)}/s  ${verdict}`);
};

const normalMiss = 1 - hitRatio;
const lostShare = 1 / cacheNodes;                         // keys that lived on the dead node
show("steady state", normalMiss);
// The dead node's keys all miss until they are reloaded elsewhere; the rest keep their hit ratio.
show("1 node dies, consistent hashing, no replica", lostShare + (1 - lostShare) * normalMiss);
// hash % N: going from 10 to 9 nodes remaps about 9 keys in 10 (the ring model measured 89.9%).
const remapped = 0.899;
show("1 node dies, hash % N", remapped + (1 - remapped) * normalMiss);
// A replica is promoted after the failover delay; only writes in the replication gap are lost.
show("1 node dies, replica promoted (after failover)", normalMiss);
// Facebook's Gutter: a small idle pool takes the dead node's keys; the paper says it turns
// 10-25% of failures into hits daily and gutter hit rates pass 35% within 4 minutes of a full failure.
show("1 node dies, gutter pool hitting 35%", lostShare * 0.65 + (1 - lostShare) * normalMiss);
show("whole cache cluster restarts empty", 1);
// How long until the cold cache is warm again, if the database is kept at its capacity?
const distinctHotKeys = 5_000_000;
console.log(`refilling ${distinctHotKeys.toLocaleString("en")} hot keys at ${dbCapacity.toLocaleString("en")}/s takes ` +
  `${Math.round(distinctHotKeys / dbCapacity)} s, if you can hold traffic back that long`);
Result
steady state                                     db  10000/s  ok
1 node dies, consistent hashing, no replica      db  29000/s  OVERLOADED 1.2x
1 node dies, hash % N                            db 180810/s  OVERLOADED 7.2x
1 node dies, replica promoted (after failover)   db  10000/s  ok
1 node dies, gutter pool hitting 35%             db  22000/s  ok
whole cache cluster restarts empty               db 200000/s  OVERLOADED 8.0x
refilling 5,000,000 hot keys at 25,000/s takes 200 s, if you can hold traffic back that long
What’s happening
  1. Steady state: 5% of 200,000 reads miss, so the database serves 10,000/s, 40% of its capacity. That headroom is what everything else spends.
  2. One node dies, consistent hashing, no replica: its 10% of keys all miss until they're reloaded onto their new owners, and the other 90% keep their 5% miss rate: 200,000 × (0.10 + 0.90 × 0.05) = 29,000/s, 1.2× capacity. Just one node out of ten pushes the database over the edge until the keys refill, which is why "the database must survive losing one cache node" belongs in the design.
  3. With hash % N, removing a node remaps about 90% of keys (the ring model measured 89.9%), so nearly the whole read load lands on the database: 180,810/s, 7.2× capacity. That's an outage, caused by the placement scheme rather than the failure.
  4. With a replica, the replica is promoted and already holds the keys, so after the failover the database load is back to 10,000/s. During the failover window (Redis Cluster's node timeout plus a second or two), reads for that shard fail or miss; the app should treat a cache error as a miss with a circuit breaker, or that window becomes a burst of database reads.
  5. Facebook's Gutter pool, from their memcache paper: about 1% of the memcached servers sit idle, and when a client gets no answer from a server it retries against Gutter, filling it on a miss with short-lived entries. With Gutter hitting 35% (the paper reports gutter hit rates passing 35% within 4 minutes of a full server failure), the database load is 22,000/s, inside capacity. It costs 1% more servers and buys time without replication.
  6. The whole cluster restarting empty sends everything to the database (8× capacity). Refilling 5 million hot keys at the database's full 25,000/s takes 200 s, and only if you can hold the rest of the traffic back meanwhile, with admission control or by warming from another cluster, which is exactly why Facebook built cold cluster warmup: a cold cluster fills its misses from a warm cluster instead of the database, bringing it back to full capacity "in a few hours instead of a few days", in the paper's words.
AdvancedReplication is asynchronous

Redis replicates asynchronously: the primary acknowledges a write and then sends it to replicas. The Redis Cluster specification is explicit that there are small windows where acknowledged writes can be lost: a write reaches a primary, the primary replies, and it fails before the replica receives the write; the promoted replica never had it. Failover takes the node timeout plus about one or two seconds for a replica to be elected. For a cache this is usually fine, because it holds copies and the worst case is a few stale or missing keys. It's not fine if you've used the cache as the only copy of something (write-behind, sessions, rate-limit counters), which is when you reach for WAIT (block until N replicas acknowledged), persistence, or a real database.

Reading from replicas (READONLY on a cluster connection) spreads read load but makes reads eventually consistent: a replica can serve a value the primary has already deleted, which reintroduces the invalidation races from earlier in a new place. It's a reasonable trade for hot read-mostly keys; for anything you just invalidated, read the primary.

AdvancedHash slots and multi-key commands

In Redis Cluster every key maps to one of 16,384 slots, CRC16(key) mod 16384, and each primary owns ranges of slots. The spec suggests clusters on the order of 1,000 nodes at most. A command touching several keys (MGET, MSET, a transaction, a Lua script) works only if all its keys are in the same slot. You control that with a hash tag: if a key contains {...}, only the part inside the first braces is hashed, so user:{42}:profile and user:{42}:cart always share a slot. The model implements the slot function and checks it against the specification's CRC16 test value.

key-slot.mjs
// Redis Cluster's key -> slot function: CRC16 (XMODEM variant) of the key, mod 16384.
function crc16(buf) {
  let crc = 0;
  for (const byte of buf) {
    crc ^= byte << 8;
    for (let i = 0; i < 8; i++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
  }
  return crc;
}
function slot(key) {
  const open = key.indexOf("{");                    // hash tag: only the part inside the first {...} is hashed
  if (open !== -1) {
    const close = key.indexOf("}", open + 1);
    if (close > open + 1) key = key.slice(open + 1, close);
  }
  return crc16(Buffer.from(key)) % 16384;
}
console.log("crc16('123456789') =", "0x" + crc16(Buffer.from("123456789")).toString(16), "(the spec's test value is 0x31c3)");
for (const k of ["somekey", "user:42:profile", "user:42:cart", "user:{42}:profile", "user:{42}:cart", "{}:empty-tag"])
  console.log(`${k.padEnd(18)} -> slot ${slot(k)}`);

// Three primaries split the slots evenly, like a fresh cluster created with 3 masters.
const owner = (s) => (s <= 5460 ? "A" : s <= 10922 ? "B" : "C");
console.log("user:{42}:profile and user:{42}:cart both live on primary", owner(slot("user:{42}:profile")));
Result
crc16('123456789') = 0x31c3 (the spec's test value is 0x31c3)
somekey            -> slot 11058
user:42:profile    -> slot 9133
user:42:cart       -> slot 12984
user:{42}:profile  -> slot 8000
user:{42}:cart     -> slot 8000
{}:empty-tag       -> slot 15110
user:{42}:profile and user:{42}:cart both live on primary B
What’s happening
  1. crc16 is the XMODEM variant of CRC16 (polynomial 0x1021, initial value 0), which is what the Redis Cluster spec uses. Its check value for the ASCII string 123456789 is 0x31c3, and the model prints exactly that.
  2. slot looks for a hash tag first: if there's a { followed later by a } with at least one character between them, only that substring is hashed. {}:empty-tag has nothing between the braces, so the whole key is hashed, as the spec requires.
  3. user:42:profile (slot 9133) and user:42:cart (12984) land in different slots, so MSET user:42:profile p user:42:cart c fails on a cluster. With the tag, both keys hash only 42 and land in slot 8000, on the same primary.
  4. Every slot in the output matches what a real Redis 8.10 node returns for CLUSTER KEYSLOT (checked in a local container), and the untagged MSET there fails with CROSSSLOT Keys in request don't hash to the same slot, while the tagged one returns OK.
  5. The design trade-off of hash tags: grouping a user's keys in one slot makes multi-key operations possible, but it also puts all of that user's traffic on one node. Tag by something with many values (a user ID), never by something with few (a country code or {global}), or you've built a hot slot on purpose.
AdvancedSizing a cache cluster

Size for both memory and throughput, and take the larger answer. The estimate below assumes a working set of 20 million keys of about 1 KB each, roughly 100 bytes of per-key overhead (key name, pointers, expiry; a rule of thumb, measure with MEMORY USAGE), 25% headroom for fragmentation and growth, nodes with 26 GB usable for data (a 32 GB machine leaving room for replication buffers and the OS), a peak of 400,000 operations a second, and about 100,000 operations a second per node (a rule of thumb for simple GETs and SETs; benchmark your own).

cache-sizing.mjs
const keys = 20_000_000, valueBytes = 1_000, overheadBytes = 100, headroom = 1.25;
const usablePerNodeGB = 26, peakOps = 400_000, opsPerNode = 100_000, replicasPerShard = 1;

const dataGB = (keys * (valueBytes + overheadBytes) * headroom) / 1e9;
const shardsForMemory = Math.ceil(dataGB / usablePerNodeGB);
const shardsForOps = Math.ceil(peakOps / opsPerNode);
const shards = Math.max(shardsForMemory, shardsForOps);
console.log(`data with headroom: ${dataGB.toFixed(1)} GB -> ${shardsForMemory} shards for memory`);
console.log(`peak ${peakOps.toLocaleString("en")} ops/s -> ${shardsForOps} shards for throughput`);
console.log(`shards: ${shards}, nodes with ${replicasPerShard} replica each: ${shards * (1 + replicasPerShard)}`);
console.log(`losing one shard's primary drops ${(100 / shards).toFixed(0)}% of keys until its replica takes over`);
Result
data with headroom: 27.5 GB -> 2 shards for memory
peak 400,000 ops/s -> 4 shards for throughput
shards: 4, nodes with 1 replica each: 8
losing one shard's primary drops 25% of keys until its replica takes over
What’s happening
  1. Memory: 20 million × 1,100 bytes × 1.25 = 27.5 GB, which fits in 2 nodes of 26 GB usable.
  2. Throughput: 400,000 operations a second at about 100,000 per node needs 4 shards. Throughput, not memory, decides the shard count here, which is common for small values.
  3. So 4 shards, each with one replica in another zone: 8 nodes. Replicas can also serve reads if eventual consistency is acceptable, which would add read capacity without more shards.
  4. The last line connects back to the failure estimate: with 4 shards, one failed primary is 25% of the keys, not 10%. Fewer, bigger shards make each failure bigger. If the database can't absorb a quarter of the keys missing for a failover window, use more, smaller shards, or make sure replicas are healthy and failover is fast.
AdvancedRedis or Memcached?
MemcachedRedis / Valkey
Data modelopaque strings, up to 1 MB per item by defaultstrings, hashes, lists, sets, sorted sets, streams, and more
Scaling outclient-side sharding (consistent hashing)Redis Cluster: 16,384 slots, redirects, automatic failover
Replication and persistencenoneasync replicas; optional RDB snapshots and AOF
Threadsmulti-threadedone main thread for commands (with I/O threads), shard to use more cores
Use it whena pure, simple, very large look-aside cacheyou also need data structures (leaderboards, counters, rate limits, queues) or replicas

Managed services set practical limits too: on Amazon ElastiCache, a cluster-mode Valkey or Redis OSS cluster has up to 90 nodes by default (from 90 shards with no replicas to 15 shards with 5 replicas each), raisable to 500 on Valkey 7.2+ or Redis OSS 5.0.6 to 7.1 (AWS docs, checked October 2026). Valkey is the open-source fork of Redis created after Redis's 2024 licence change; the Databases course covers the licensing story.

The MGET that fails in production

A service runs MGET cart:42 profile:42 prefs:42 against a single Redis in testing, and it works. In production, on Redis Cluster, it fails with CROSSSLOT Keys in request don't hash to the same slot. Why, and what are the two fixes?

Show answer

The three keys hash to different slots, which may live on different nodes, and Redis Cluster doesn't run multi-key commands across slots. Fix it with a hash tag, or by splitting the command per slot.

What’s happening
  1. On a single Redis, every key lives on the same server, so MGET of any keys works. On a cluster, each key is placed by CRC16(key) mod 16384, and cart:42, profile:42 and prefs:42 hash to three different slots.
  2. A multi-key command must be executed by one node atomically, so Redis Cluster rejects it unless all keys are in one slot, even if those slots happen to be on the same node, with exactly that CROSSSLOT error (reproduced above on Redis 8.10).
  3. Fix 1, hash tags: rename the keys {user:42}:cart, {user:42}:profile, {user:42}:prefs. Only user:42 is hashed, so all three land in the same slot and MGET works, atomically, in one round trip.
  4. Fix 2, split by slot: let the client library group the keys by slot and send one MGET per node, in parallel (many cluster clients offer this). It keeps the keys spread across the cluster, but the read is no longer atomic and costs a round trip per node.
  5. Choose hash tags when the keys belong to one entity and are used together; choose splitting when the keys are unrelated, so tagging them would pile unrelated traffic onto one slot.

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.