Deep dive 1 · 20 min

Caching Techniques

A cache trades freshness and memory for speed. The technique you choose decides what happens when the cache is stale, cold, or down.

Say it in 30 seconds
  • ~1 mscache hit, one Redis round trip
  • 0.65%Bloom filter false positives, 100K keys in 1M bits
  • 80% of TTLwhen refresh-ahead triggers a background refresh
Show what to say

“I default to cache-aside: read Redis first, and on a miss read the database and fill the key with a TTL. On a write I update the database and then delete the key rather than set it, since two concurrent writers can land cache writes in the opposite order. I switch to write-through or write-behind only when I need fresh reads or fast writes.”

Trade-offs

  • Cache-aside vs write-through: cache-aside is simple and only slower if Redis dies; write-through keeps reads fresh but writes cost double.
  • Write-behind vs write-through: write-behind absorbs fast writes, but unflushed writes are lost if the cache dies.
  • L1 in-process vs Redis only: L1 answers in microseconds and absorbs hot keys, but each instance holds its own copy, needing pub/sub invalidation.

Bring it up before they ask

Single flight only collapses misses within one process, so 50 app instances can still send 50 rebuilds; add a Redis lock across instances.

New to the words? Read the one-minute primer

0 of 0 sections ticked
30-second answer

Pick a pattern by what you can live with when the cache is stale, cold or down. Start with , delete on write, and add a fix only for the failure you can name.

Why the technique matters

A is a copy, and copies drift.

Read more · 91 words

Each pattern decides who updates the copy, and when. That sets how stale readers can be, what an empty cache does after a restart, and what you lose if it dies. More in the Caching lesson.

The lesson names the patterns and the failures. This page draws the read and write paths, then the races, the two-level cache, the and the lock, with code.

The read and write paths

Before walking to the archive room, glance at the notepad on your desk.

Read more · 623 words

A is that notepad: fast memory next to your app that holds the answers you ask for most.

With , the app checks the cache first. A hit answers in about 1 ms. A miss goes to the database (about 50 ms), then saves the answer for next time. Pick hit or miss in the diagram.

Each saved answer gets a , a timer that expires it. Add a little random . Without it, a that expires at noon sends all 5,000 reads a second to the database at once. That pile-up is a .

Do the sum first. The is the share of reads the cache answers. Take 10,000 reads a second:

Hit ratioAverage readDatabase sees
50%about 25 ms5,000 a second
90%about 6 ms1,000 a second
99%about 1.5 ms100 a second

At 50% you added a hop and saved little. Aim for 90% or more. Skip the cache when the database already answers in a few ms and holds up. Skip it for data read once, data that changes every second, and values that must never be stale (a balance). Fix the slow query or add an index first.

When data changes, the notepad is wrong. Update the database, then delete the cached copy. The next read misses and loads the truth.

Why delete beats overwrite. Two writers can reach the cache in the opposite order to the database. Writer A sets the price to 10, writer B sets it to 12, and the database ends at 12. If B's cache write lands first and A's lands late, keeps 10 until the TTL. A delete cannot be out of order: whichever lands last, the key is empty and the next read loads 12. Overwrite only when a single writer owns the key.

Where the delete can still fail. If the app dies after the database write and before the DEL, the old value stays until the TTL. So every cached key needs a TTL: 300 s puts a hard ceiling on the damage. When a lost delete is costly, write a change event in the same as the row (an ), and let a worker delete the key and retry until it works.

Press Play to watch it run · 4 steps

The app asks Redis first. On a hit it answers in about 1 ms without touching the database; on a miss it reads the database and fills the cache with a TTL.

Cache-aside readThe app asks Redis first. On a hit it answers in about 1 ms without touching the database; on a miss it reads the database and fills the cache with a TTL.GET /products/42GET product:42alt [cache hit]JSON value (~1 ms)The database is never touched200 OK
  • Client
  • Service
  • Cache
  • Database
Text description
  1. Client → App: GET /products/42
  2. App → Redis: GET product:42
  3. Redis replies to App: JSON value (~1 ms)
  4. App replies to Client: 200 OK

Press Play to watch it run · 12 steps

The app updates the database, then deletes the cache key. The next read misses, loads the new row and refills the cache.

Write, then invalidateThe app updates the database, then deletes the cache key. The next read misses, loads the new row and refills the cache.PUT /products/42 price=12UPDATE products SET price = 12OKDEL product:42Delete the key, don't update it200 OKLater: the next readGET /products/42GET product:42nil (miss)SELECT … WHERE id = 42price = 12SET product:42 EX 300price = 12Fresh until the next write or the TTL
  • Client
  • Service
  • Cache
  • Database
Text description
  1. Admin → App: PUT /products/42 price=12
  2. App → Database: UPDATE products SET price = 12
  3. Database replies to App: OK
  4. App → Redis: DEL product:42
  5. App replies to Admin: 200 OK
  6. Shopper → App: GET /products/42
  7. App → Redis: GET product:42
  8. Redis replies to App: nil (miss)
  9. App → Database: SELECT … WHERE id = 42
  10. Database replies to App: price = 12
  11. App → Redis: SET product:42 EX 300
  12. App replies to Shopper: price = 12

Six patterns compared

Each pattern answers one question: who keeps the copy fresh?

Read more · 633 words

Start at and leave it only when a question is a firm yes.

PatternHow it worksIf the cache diesUse it whenIt costs
Cache-aside (default)App updates the DB, deletes the key; app reads cache, loads on a missSlower, still correctReads outnumber writes about 10 to 1 or more: almost every product, profile and catalog readOne slow request per cold key; a stale window as long as the TTL when a delete is lost
Read-throughCache-aside where the cache loads misses itself: the app calls get(key) and a good loader runs once per key, even when 100 callers ask at onceSlower, still correctYou want the loading code in one place. Plain Redis has no loader; libraries (Caffeine, Guava loading caches) and products such as Amazon DAX in front of DynamoDB doA cache that must know how to reach your database; a library tied to your language
Write-throughWrites the database first, then the cache, then confirms (a rejected write never reaches the cache). A read after a write is always a hit on the new value: about 51 ms instead of 50 msStill correctA user must see their own change at once (a profile bio, a setting) and writes are modestCache space for rows nobody reads again; skip it for write-heavy data
Write-behindWrites the cache now, the database later, in batches. Repeated writes to one key collapse: 1,000 view-count updates a second flushed every 5 s become one UPDATE per 5 sUnflushed writes are lostLosing a few seconds is fine and the same keys are hit over and over: view counts, likes, metrics. Never orders or moneyA persisted buffer (an append-only log) and a short flush interval narrow the loss window and never close it
Write-aroundWrites go straight to the database; reads fill the cache. Write-once data (logs, audit rows, uploads) never pushes hot keys outStill correctThe first read is far from the write, or may never comeOne slow first read, and a trap: if the key was already cached, the write leaves a stale copy. Delete the key on write whenever it can exist
Refresh-aheadReloads a hot key in the background once most of its TTL is gone: with a 300 s TTL, the first read after 240 s (80%) starts the reload while the old value keeps servingSlower, still correctA few keys are expensive to rebuild and read constantly: an 800 ms homepage query at 5,000 reads a second would otherwise stall about 4,000 requests at each expiryExtra reloads for keys nobody asks for again: keep it to the top handful, and let only one instance reload (a SET ... NX flag)

In a flash sale, 20,000 order writes a second land in a . If it dies 4 s into a 5 s window, about 80,000 orders are gone.

Walk the tree for your data, one question at a time.

Then watch the patterns side by side. Each tab writes price = 12, then reads it twice. The bar shows how long a reader could still see the old price. Only write-behind leaves the database behind the cache.

Play Writes collapse, then Crash before flush, to see why write-behind is fast and risky.

Which pattern for this data?yesyesyesyesnonononoWrite-throughWrite-behindWrite-aroundAside + refresh-aheadCache-aside (default)Do a few hot keys hurtwhen they expire?Written once and rarelyread again?Same few keys hitconstantly, and losingseconds is fine?Must a reader see awrite at once?

Must a reader see a write at once?

Interactive

Caching patterns side by side

Same story in every tab: write price = 12 (it was 10), then read it twice.

Applast read: 12Cacheprice = 12DBprice = 12stale?writeUPDATE price = 12OKDEL priceread 1GET pricenil (miss)SELECT price12got 12SET price 12 EX 300read 2GET price12 (hit)got 12

Staleness windowOnly between the UPDATE and the DEL (about a millisecond). If the DEL fails, until the TTL expires.

Cache crash: no data lostNothing is lost: the database already has 12. Reads just miss and reload.

Step 10 of 10 (read 2): Cache → App: 12 (hit). Cache price = 12, DB price = 12. Every reader sees 12.

Press Play to watch it run · 3 steps

Writes land in the cache and are acknowledged at once. A timer flushes only the latest value per key to the database, so repeated writes to one key collapse into one.

Write-behind: many writes, one flushWrites land in the cache and are acknowledged at once. A timer flushes only the latest value per key to the database, so repeated writes to one key collapse into one.writeflushAppCache + bufferflush every 5 sDatabase

Tap any box to see what it is and what it talks to.

  • Service
  • Cache
  • Database
Open in playground
Text description
  1. A viral video gets 1,000 view-count updates a second. Each is acknowledged in about 1 ms.
  2. The buffer keeps one number per video: the latest total.
  3. After 5 seconds one UPDATE goes out. 5,000 writes became 1, so the database load dropped 5,000 times for this key.

Races the delete does not fix

Deleting on write is the right default, but a reader that was already in flight can undo it.

Read more · 377 words

The window is small and the bug is invisible in tests, which is why it ships.

The slow reader. Reader A misses, reads price 10 from the database, then pauses (a GC pause, a slow network hop). The admin sets 12 and deletes the key. A wakes and runs SET price = 10, and shoppers see 10 for up to the whole . On a cold with 5,000 misses a second and a 50 ms load, about 5,000 x 0.05 = 250 loads are in flight at any moment, and any that began before the write can refill the old value.

Three fixes, from cheap to exact. Do not stack all three on every key.

FixHow it worksUse it whenIt costs
TTL + second deleteA TTL bounds the damage; a delayed second delete about 1 s after the write shrinks itStale is embarrassing but not wrong (a price in a list). Always set the TTLThe delay must be longer than your slowest load, and a longer pause still beats it. Needs a delayed job (a queue with a delay). It narrows the race and does not close it
Versioned keysKeep a counter (ver:user:42), bump it with INCR on each write, read user:42:v7 after reading the counter. A slow reader holding v7 can only write to the dead v7 keyA stale value is a bug: permissions, prices at checkout, feature flagsA second Redis round trip per read (about 1 ms more) and orphaned old keys, which the TTL removes
Fill only if newerThe cache refuses a value older than what it has seen. The writer leaves a tombstone at the new version, so a fill carrying v1 is rejected and one carrying v2 is accepted. In Redis, run the compare and the set as one Lua script so they cannot interleaveYou need the exact fixA script and a version on every row

Press Play to watch it run · 6 steps

A reader loads the old row, stalls, and writes it into the cache after the writer has already deleted the key. The cache now holds a value the database no longer has.

The slow reader refills the old priceA reader loads the old row, stalls, and writes it into the cache after the writer has already deleted the key. The cache now holds a value the database no longer has.GET price (miss)SELECT price10 (old)Reader stalls: GC pause or slow networkUPDATE price = 12DEL priceSET price = 10Stale 10 sits in Redis until the TTL
  • Service
  • Database
  • Cache
Text description
  1. Reader → Redis: GET price (miss)
  2. Reader → Database: SELECT price
  3. Database replies to Reader: 10 (old)
  4. Writer → Database: UPDATE price = 12
  5. Writer → Redis: DEL price
  6. Reader → Redis: SET price = 10

Press Play to watch it run · 4 steps

The value lives under a key that includes the row version. A write bumps the version, so readers move to a new key and the old one is never read again.

Versioned keysThe value lives under a key that includes the row version. A write bumps the version, so readers move to a new key and the old one is never read again.versionvalueloadAppVersion counterver:user:42Versioned valueuser:42:v7Database

Tap any box to see what it is and what it talks to.

  • Service
  • Cache
  • Database
Open in playground
Text description
  1. The app asks for the current version of user 42.
  2. Version 7.
  3. It reads user:42:v7.
  4. Hit. Cost: two Redis round trips instead of one.
versioned-cache.js
// Fill-if-newer: a cache that refuses a stale fill. Every row carries a version that rises on each write.
// invalidate() leaves a tombstone at the new version, so a slow reader still holding version 1 cannot put its copy back.
// In Redis you would run both steps as one Lua script so the compare and the set cannot interleave.
export class VersionedCache {
  #items = new Map();                                   // key -> { value, version, dead }

  get(key) {
    const e = this.#items.get(key);
    return e && !e.dead ? e.value : undefined;          // a tombstone reads as a miss
  }

  fill(key, value, version) {
    const e = this.#items.get(key);
    if (e && (e.version > version || (e.version === version && !e.dead))) return false;   // stale or duplicate
    this.#items.set(key, { value, version, dead: false });
    return true;
  }

  invalidate(key, version) {
    const e = this.#items.get(key);
    if (e && e.version > version) return false;         // a newer write already got here
    this.#items.set(key, { value: undefined, version, dead: true });
    return true;
  }
}

Quick checkA slow reader refills the old value after your delete. Which fix is exact?

Stampedes, hot keys and other failures

Every failure here ends on your database.

Read more · 1311 words

The was not only faster; it was a shield.

. A expires while thousands of requests want it, and every one rebuilds the same value. If the rebuild takes 800 ms and 5,000 requests arrive each second, 5,000 x 0.8 = 4,000 requests all run the same heavy query. Play the three scenarios in order.

  • (inside one process): the first miss starts the rebuild and every concurrent caller waits on the same promise. A few lines (the Code section has it), no cost, and it fixes the worst case inside one instance. It does not help across instances: 50 instances still send 50 rebuilds.
  • A lock (across instances): SET lock:home token NX PX 5000 succeeds for one caller; the rest retry the cache after about 50 ms. The timeout frees the lock after 5 s if the holder crashes, and releasing only if the token still matches stops a slow holder from deleting someone else's lock. Use it when a rebuild is expensive (hundreds of ms or a heavy query) and the key is hot. For cheap rebuilds, single flight is enough.
  • Serve stale while you rebuild: keep the old value for a grace period and hand it out while one request refreshes it, so nobody waits.
  • the : 1,000 keys loaded together with a 300 s TTL all expire in the same second (an avalanche), while a random 270 to 330 s spreads the same expiries over about 60 s, roughly 17 a second.

Hot key. A two-level cache puts a tiny map inside each app instance (L1) in front of Redis (L2). A hit in L1 costs microseconds and no network. Play L1 hit, L2 hit and Both miss.

With a short L1 TTL, each instance asks Redis for a key once per TTL, however many requests it serves. A key read 100,000 times a second across 50 instances, with a 5 s L1 TTL, costs Redis 50 / 5 = 10 reads a second instead of 100,000. That cures a hot key that saturates the one Redis node owning it.

  • Use it when a few keys are read thousands of times a second and a stale copy of up to 5 s is fine: , config, a celebrity profile. Do not use it for balances, stock counts or permissions.
  • Keep the copies honest. The writer deletes the Redis key and publishes the key name; each instance drops its L1 entry. is fire and forget: an instance that is reconnecting misses the message and keeps the old copy. So the L1 TTL is your staleness bound: choose it as the longest wrong answer you accept. Redis 6 and later can also push invalidations to clients (client-side caching with tracking).
  • Cap the size of L1 (an of a few thousand entries): 50 copies of a large map are 50 times the memory.

Penetration. A scraper tries random user ids at 10,000 a second; every request misses Redis and finds nothing in the database.

  • Cache the not-found for 30 to 60 s. Cheap and right for ordinary mistakes (a deleted product, a mistyped link). It fails against a scraper that never repeats an id: at 10,000 unique ids a second and a 60 s TTL you would store up to 600,000 empty keys a minute, pushing real data out of memory.
  • A of real ids. A bit array in app memory that answers "definitely not present" or "maybe present". A "definitely not" is a 404 with no Redis and no database. It never says "no" wrongly, and sometimes says "maybe" wrongly: that false-positive rate is the one number to choose. Size it from the item count n and the rate p: bits = -n x ln(p) / (ln 2) squared, and hashes = bits / n x ln 2.
Target rateBits per idHashes100K ids need
1%9.67about 120 KB
0.1%14.410about 180 KB

A 120 KB filter in every app instance can stop nearly all of a random-id attack. The filter must learn a new id before anyone can read it, or the real user gets a wrong 404: add the id to the filter before (or in the same step as) inserting the row. You cannot delete from a plain filter (clearing a bit may erase another id), and it does not grow, so rebuild it from the table on a schedule, sized for the final count.

Cold cache after a restart. stays correct when Redis dies, but the database then takes the full read load. At 10,000 reads a second and a 90% it sees 1,000. After a restart the hit ratio is 0%, so it sees 10,000: ten times its normal load, at the moment it is slowest because every load also refills the cache. Four defences, in order of cost:

  1. Persistence (an RDB snapshot or AOF), so Redis restarts with data.
  2. A warm-up script that loads the top 1,000 keys before traffic returns.
  3. A cap on concurrent database loads with a bounded queue.
  4. A to fail over to, so a node loss is not a cold start.

A sick Redis is worse than a dead one, because every request waits on it. Set a client timeout of a few tens of ms, then skip the cache and read the database under the same concurrency cap. A stops the app from trying a failing cache on every request. Fail open for reads; for writes you must not lose, never depend on the cache to accept them.

Press Play to watch it run · 3 steps

The homepage key expires at noon and 5,000 requests a second want it. Compare no protection, single flight in each process, and a Redis lock across processes.

A hot key expiresThe homepage key expires at noon and 5,000 requests a second want it. Compare no protection, single flight in each process, and a Redis lock across processes.getrebuildReaders5,000 / sApp instancesx 50RedisDatabaserebuild ~800 ms

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Cache
  • Database
Open in playground
Text description
  1. Noon: the key expires. Requests keep arriving at 5,000 a second.
  2. Every one of them misses.
  3. The rebuild takes 800 ms, so 5,000 x 0.8 = 4,000 requests all run the same heavy query.

Press Play to watch it run · 2 steps

Each app instance checks its own memory first (L1, microseconds), then shared Redis (L2, about 1 ms), then the database (about 50 ms).

Reading through L1, then L2Each app instance checks its own memory first (L1, microseconds), then shared Redis (L2, about 1 ms), then the database (about 50 ms).L1 missL2 missClientApp instanceL1, µsRedisL2, ~1 msDatabase~50 ms

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Cache
  • Database
Open in playground
Text description
  1. A request for a hot feature flag reaches instance 12.
  2. Found in its own map. No network hop at all.
bloom-size.js
// How big must a Bloom filter be? For n ids and a target false-positive rate p:
//   bits m = -n * ln(p) / (ln 2)^2      hashes k = (m / n) * ln 2
// and the rate you actually get from m bits, k hashes and n ids is (1 - e^(-k*n/m))^k.
export function bloomSize(n, p) {
  const m = Math.ceil((-n * Math.log(p)) / Math.LN2 ** 2);
  const k = Math.max(1, Math.round((m / n) * Math.LN2));
  return { bits: m, hashes: k, bytes: Math.ceil(m / 8) };
}

export function falsePositiveRate(bits, hashes, n) {
  return (1 - Math.exp((-hashes * n) / bits)) ** hashes;
}

Quick check50 instances use single flight; a hot key expires. How many rebuilds can run?

Code

Cache-aside (lazy loading)

cache-aside.js
// Check the cache, load the database on a miss, then store the result with a TTL
export async function getItem(id) {
  const key = 'item:' + id;
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);
  const row = await db.find(id);
  await redis.set(key, JSON.stringify(row), 'EX', 300);
  return row;
}

Write-through

write-through.js
// Every write updates the database and the cache together, so the next read is fresh
export async function save(id, data) {
  await db.save(id, data);                                           // the source of truth first
  await redis.set('item:' + id, JSON.stringify(data), 'EX', 3600);   // then the copy
}

Write-behind (write-back)

write-behind.js
// Batch writes in memory and flush every 5 s. If a flush fails, put the batch back without
// overwriting newer values, and never run two flushes at once.
const buffer = new Map();
let flushing = false;
export const write = (id, value) => buffer.set(id, value);       // the latest value per id wins

setInterval(async () => {
  if (flushing || buffer.size === 0) return;
  flushing = true;
  const batch = [...buffer];
  buffer.clear();
  try {
    await db.bulkUpsert(batch);
  } catch (err) {
    for (const [id, value] of batch) if (!buffer.has(id)) buffer.set(id, value);   // retry next tick
    log.warn({ err, size: batch.length }, 'write-behind flush failed');
  } finally {
    flushing = false;
  }
}, 5_000);
// A process crash still loses whatever is in `buffer`. That's the trade-off you accept with write-behind.

Invalidation

invalidation.js
// Option 1: delete on write. Every key also has a TTL, the safety net for races you didn't think of.
export async function updateItem(id, data) {
  await db.update(id, data);
  await redis.del('item:' + id);                  // delete, do not update
}

// Option 2: versioned keys. Bump user.version on every write and old entries are simply never read again.
export const userKey = (user) => 'user:' + user.id + ':v' + user.version;   // versioned key

Stampede protection (single flight)

One request rebuilds an expired ; the rest share the result.

Read more · 44 words
single-flight.js
// Single flight: when a hot key expires, one call rebuilds it and every concurrent caller shares its promise.
// Per process only: across 50 app instances, combine it with the Redis lock from the Caching lesson.
const inflight = new Map();

export function load(key, fn) {
  if (!inflight.has(key))
    inflight.set(key, fn().finally(() => inflight.delete(key)));   // forget it once settled, even on error
  return inflight.get(key);
}

Two-level cache (L1 + L2)

Each instance keeps its own copy, so invalidate over and keep L1 short.

two-level-cache.js
// Two-level cache: an in-process LRU (L1, microseconds) in front of Redis (L2, ~1 ms).
// Invalidation fans out over pub/sub. Pub/sub can drop messages, so L1 entries also expire quickly.
const l1 = new Map();                                    // key -> { value, exp }
const L1_TTL_MS = 5_000;
const sub = redis.duplicate();
await sub.subscribe('invalidate');
sub.on('message', (_channel, key) => l1.delete(key));    // every instance drops its copy

export async function get(key, load) {
  const e = l1.get(key);
  if (e && e.exp > Date.now()) return e.value;
  const cached = await redis.get(key);
  const value = cached ? JSON.parse(cached) : await load(key);
  if (!cached) await redis.set(key, JSON.stringify(value), 'EX', 300);
  l1.set(key, { value, exp: Date.now() + L1_TTL_MS });   // cap l1's size in production (e.g. the LRU from Learn 4)
  return value;
}
export async function invalidate(key) {
  await redis.del(key);
  await redis.publish('invalidate', key);
}

Refresh-ahead

Past 80% of the TTL, a read triggers a background refresh.

refresh-ahead.js
// Refresh-ahead: once 80% of the TTL has passed, a read triggers a background refresh,
// so hot keys never actually expire for readers
export async function getWithRefreshAhead(key, load, ttlSec = 300) {
  const [[, value], [, ttl]] = await redis.multi().get(key).ttl(key).exec();
  if (value && ttl > 0 && ttl < ttlSec * 0.2) {
    redis.set(`refresh:${key}`, 1, 'EX', 30, 'NX')                    // only one instance refreshes
      .then(won => won && load(key).then(v => redis.set(key, JSON.stringify(v), 'EX', ttlSec)))
      .catch(() => {});                                                  // background work must never crash the read
  }
  if (value) return JSON.parse(value);
  const fresh = await load(key);
  await redis.set(key, JSON.stringify(fresh), 'EX', ttlSec);
  return fresh;
}

Bloom filter against penetration

Ids that don't exist always hit the database.

Read more · 46 words

A of real ids rejects them first.

With 100K keys in 1M bits, about 0.65% are false positives.

bloom-filter.js
// Bloom filter in front of the cache: "definitely not present" lets you skip both the cache
// and the DB for ids that don't exist (cache penetration). 1M bits + 7 hashes → ~0.65% false positives at 100K keys.
const fmix = h => { h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; return h >>> 0; };
const hash = (s, seed) => { let h = seed; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return fmix(h); };

export class BloomFilter {
  constructor(bits = 1 << 20, hashes = 7) { this.m = bits; this.k = hashes; this.bits = new Uint8Array(bits >> 3); }
  #positions(key) {                                      // double hashing with two independent seeds
    const h1 = hash(key, 0x811c9dc5), h2 = hash(key, 0x9747b28c) | 1;
    return Array.from({ length: this.k }, (_, i) => ((h1 + i * h2) >>> 0) % this.m);
  }
  add(key) { for (const p of this.#positions(key)) this.bits[p >> 3] |= 1 << (p & 7); }
  mightContain(key) { return this.#positions(key).every(p => this.bits[p >> 3] & (1 << (p & 7))); }
}

Eviction policy

Read more · 70 words
# redis.conf for a pure cache: evict any key by recency (or allkeys-lfu by frequency).
# Use volatile-lru when some keys have no TTL and must never be evicted.
maxmemory 2gb
maxmemory-policy allkeys-lru

Caching techniques in the interview

Bring these techniques up after you have said " with ", when the interviewer pushes on what could go wrong.

Read more · 509 words

Say the decisions in this order.

  1. Start with cache-aside and delete on write

    "Read Redis, load on a miss, with . On a write I update the database and delete the key, never overwrite it."

  2. Change the pattern only for a reason

    if users must read their own write, for counters you can lose seconds of, write-around for write-once data.

  3. Name the race

    A slow reader can refill the old value after the delete. A TTL bounds it; versioned keys or a fill-only-if-newer script close it.

  4. Protect the database

    plus a Redis lock for a , a or cached not-found for missing ids, jittered TTLs against avalanche.

  5. Say what happens when the cache dies

    Reads fall back to the database under a concurrency cap. Warm the hot keys before traffic returns.

If the interviewer saysYou raise
"Users must see their own edit"Write-through, or delete on write and read your own write
"50,000 counter updates a second"Write-behind, flushed every few seconds, loss accepted
"One key gets 100K reads a second"L1 per instance with a 5 s TTL and pub/sub invalidation
"Lookups for ids that don't exist"Cache not-found, then a Bloom filter of real ids
"Redis restarts"Warm the top keys, cap database loads, persist or fail over

Quick checkWhich write path do you propose first for a read-heavy product page?

Interview questions

Cache-aside or write-through?

by default. when users must read their own writes.

How do you keep in-process caches consistent across 50 instances?

invalidations plus short L1 , since a missed message leaves a stale copy until the TTL.

When is write-behind acceptable?

When losing a few seconds is fine (view counters), never for money or orders.

What's the difference between cache-aside and read-through?

Who runs the miss path: your code in cache-aside, the 's own loader in read-through.

Requests for non-existent ids are hammering your database. What do you do?

Cache "not found" briefly, add a of real ids, and rate-limit callers.

You delete on write, yet a stale price appears. How?

A slow reader loaded the old row before the write and refilled it after the delete. Fix with a TTL, a delayed second delete, or versioned keys.

How does a Bloom filter stay correct as new ids are created?

Add the id to the filter before or with the row insert, since a late add gives the real user a 404. Rebuild it on a schedule because it cannot delete.

Redis restarts empty. What protects the database?

Persistence or a , a warm-up of the hottest keys, and a cap on concurrent loads.

Check yourself

Question 1 of 5

Score 2 out of 3 or better to mark this page complete.

1. Which pattern can lose acknowledged writes?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.

Next: When one database can no longer hold or serve all the data, you shard it.