Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

24 chapters
⌕
Beginner7›
B1The mental modelB2Client-server basicsB3Vertical vs horizontalB4Databases in designB5Caching fundamentalsB6APIs & communicationB7Walkthrough: URL shortener
Intermediate9›
I1Load balancing in depthI2CDNsI3Message queues & asyncI4Consistency modelsI5Rate limiting & throttlingI6Designing for availabilityI7Storage systemsI8Search systemsI9Walkthrough: feed/chat
Advanced8›
A1CAP theorem in depthA2Sharding at scaleA3Distributed consensusA4Fault toleranceA5Observability at scaleA6Capacity estimationA7Case studiesA8Tradeoff thinking
/ search[ ] chaptert top

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
B5

Caching fundamentals

A cache is one bet placed at seven distances — and the interesting number is never the hit rate, it's the miss rate.

Every cache is the same bet, made at a different distance

A cache exists because of exactly one wager: this answer will be asked for again before it changes. Every caching layer in every architecture is that same bet — the layers differ only in how far from the user the copy sits, who pays when the bet is wrong, and how hard it is to take the copy back. Interviewers ask about caching constantly, not because inserting a Redis is hard, but because it is the fastest way to find out whether you reason about a system in rates and distributions or in adjectives. A candidate who says "we'll add a cache" and a candidate who says "at a 90% hit rate this still sends 50k reads a second to the primary, so 90% isn't the goal" are two very different hires.

one request, six chances to never reach disk browser CDN edge reverse proxy app memory Redis tier DB buffer pool 0 ms 10-30 ms 1-3 ms ~100 ns 0.3-1 ms ~0.1 ms SSD 0.1-1 ms each layer removes work from every layer to its right the leftmost hit is the cheapest — and the hardest to invalidate a full miss pays every hop, not just the last one
Notice the asymmetry: latency improves by four orders of magnitude as you move left, and your ability to revoke a stale copy gets worse by roughly the same amount.

What each layer actually buys you

LayerCachesTypical TTLWho can invalidate itWhat it really buys
Browser / HTTP cacheStatic assets, GET responses, service-worker dataMinutes to a year (fingerprinted assets)Nobody. Once served, it is gone until it expiresRemoves the request entirely — the only layer that saves network, not just work
CDN edgeAssets, whole pages, API GETs, imagesSeconds to daysYou, via purge — but propagation takes 1-30 sCuts RTT from ~120 ms to ~20 ms and absorbs 80-99% of read traffic before it reaches your region
Reverse proxy (nginx, Varnish, Envoy)Rendered fragments, upstream responsesSeconds to minutesYou, instantly — it's your boxShields origin from duplicate work; the natural home for request coalescing
In-process (Caffeine, an LRU Map)Config, feature flags, hot rows, compiled templatesSeconds to minutesOnly that process; every instance has its own copy~100 ns lookups, zero network — but N instances means N copies and N stale windows
Shared cache tier (Redis, Memcached)Objects, session state, computed aggregates, rate-limit countersSeconds to hoursYou, atomically, for the whole fleetOne consistent copy across all app servers; the workhorse layer
Database buffer poolPages the storage engine recently touchedUntil evictedNobody — and you don't want toFree, already on; the reason "the database is slow" is usually false for hot data

The buffer pool is the layer candidates forget exists, and it changes the conversation. A Postgres box with 64 GB of RAM and a 40 GB hot set is already serving nearly every read from memory. Adding Redis in front of it does not save you a disk seek — it saves you connection setup, query parsing, planning, MVCC visibility checks and result serialisation. That is still worth 5-20× on latency, but say why, because "the database reads from disk" is frequently wrong and an interviewer who runs databases will notice.

Say it like this → "I'd cache at two layers here. CDN for the read-only public content, because that removes traffic from my region entirely, and a shared Redis tier for per-user objects, because those need one consistent copy across the fleet. I'd deliberately skip a per-instance in-memory cache for anything mutable — N instances means N independent stale windows and no way to purge them."

Read strategies: cache-aside vs read-through

Cache-aside (also "lazy loading") puts the application in charge: ask the cache, and on a miss go fetch and populate it yourself. This is the one you will be asked to write on a whiteboard, so write it well — the three-line version everyone produces has three real bugs in it.

async function getUser(id) {
  const key = `user:v3:${id}`;                // v3 = payload schema version; see invalidation
  const hit = await redis.get(key);

  // !== null, not truthy: a legitimately cached 0, "" or false is a HIT.
  // if (hit) is the single most common bug in this function.
  if (hit !== null) return JSON.parse(hit);

  const row = await db.users.findById(id);    // the miss path — the expensive part

  if (row === null) {
    // Negative caching. Without it, a scraper hitting nonexistent ids
    // passes straight through the cache into the database, every time.
    await redis.set(key, "null", "EX", 30);
    return null;
  }

  // Jittered TTL: 300 s ± 30. A million keys populated in the same minute
  // must not expire in the same second. This one line prevents avalanche.
  const ttl = 300 + Math.floor(Math.random() * 61) - 30;
  await redis.set(key, JSON.stringify(row), "EX", ttl);
  return row;
}

Note what this function does not do: it never writes to the cache on the write path. The write path deletes the key and lets the next read repopulate it. That asymmetry is deliberate and we'll see why in the invalidation section.

Read-through moves that same logic behind the cache client, so the application only ever talks to the cache and the cache knows how to load a miss. Cleaner code, one place to implement coalescing and metrics — but the cache is now on the critical path for correctness, not just speed. With cache-aside, Redis being down means slow. With read-through, Redis being down means down. That distinction is the whole answer when an interviewer asks which you'd pick.

Write strategies, and when each is right

StrategyWhat the write doesCostFailure modeReach for this when…
Cache-aside + invalidateWrite DB, then DEL the keyNone on the write pathA read/write interleaving can leave a stale entry until TTLThe default. Pick this unless you can name why not
Write-throughWrite cache and DB synchronously, both must succeedEvery write pays both latenciesCaches data that may never be read; a cache outage stalls writesThe same key is read within seconds of being written (profile edit, cart update) and write latency budget is loose
Write-behind (write-back)Write cache, ack the client, flush to DB in batches laterDurability — the ack is a lie until the flush landsCache node dies with unflushed writes; ordering across keys is hardHigh-volume, low-value-per-write, coalescable data: view counts, likes, "last seen", metrics
Write-aroundWrite DB only, never touch the cacheFirst read after a write is always a missNothing, which is the pointBulk imports, logs, audit rows — write-once data that would otherwise evict your hot set
Refresh-aheadProactively recompute an entry before its TTL expiresWasted work on keys nobody asks for againAmplifies load if applied to a large keyspaceA small, known set of extremely hot keys — a homepage feed, a leaderboard, a config blob

Write-behind is the one worth volunteering. A "like" counter taking 50k increments a second is 50k row updates a second with lock contention on a single hot row — a database will simply refuse. Buffer the increments in Redis, flush the delta every second, and 50k writes become one. You have traded "we might lose the last second of counts if a Redis node dies" for a 50,000× reduction in write load, and for a like counter that is obviously the right trade. Say the trade out loud; that is the whole point of the answer.

Eviction: what happens when memory runs out

TTL is expiry — the entry becomes invalid at a known time. Eviction is what the cache does when it is full and someone wants to write anyway. They are different mechanisms and conflating them is a tell.

PolicyKeepsBreaks onReach for this when…
LRURecently accessed keysScans — one analytics query touching a million cold rows flushes your entire working setGeneral purpose, access is recency-correlated (sessions, recent items)
LFUFrequently accessed keysAging — yesterday's viral post keeps a slot forever unless counters decayA stable long-tail popularity distribution; scan resistance matters
FIFO / randomNothing in particularNothing badly, surprisinglyYou need O(1) with zero bookkeeping; random eviction is within a few points of LRU in practice
TTL-only (volatile-ttl)Entries furthest from expiryKeys written without a TTL — they become unevictableEvery entry genuinely has a natural lifetime
W-TinyLFU (Caffeine)Whatever a frequency sketch says earns its slotVery little; near-optimal hit ratesIn-process JVM caches where the extra few percent of hit rate is worth a real library

Two implementation details worth knowing because they get asked. First, Redis does not implement true LRU: it samples a handful of keys (maxmemory-samples, default 5) and evicts the least recently used of the sample. It gets within a percent or two of exact LRU for a fraction of the bookkeeping, and it is a nice example of the approximate-is-fine reasoning these systems are built on. Second, Redis LFU counters are 8-bit probabilistic counters with logarithmic increment and time-based decay — you cannot store a true frequency for a billion keys, so you store something that ranks them correctly and costs one byte.

⚠ Running a datastore under an eviction policy Sessions, idempotency keys, rate-limit state and distributed locks are frequently parked in "the Redis" — which is configured allkeys-lru because it is a cache. Under memory pressure it will silently evict a session and log a user out, or evict an idempotency key and let a duplicate payment through. Anything whose loss is a correctness bug belongs in a separate instance set to noeviction, where a full memory condition fails writes loudly instead of corrupting state quietly.

Invalidation is hard, and here is exactly why

"There are two hard things in computer science" is a joke everyone repeats and almost nobody unpacks. The concrete reason is this: there is no transaction that spans your cache and your database. They are two independent systems, so any two operations against them can interleave, and one specific interleaving is permanently damaging.

reader A writer B GET key → miss SELECT → v1 UPDATE → v2 committed DEL key → nothing there SET key = v1 (stale) A is holding v1 across this entire window t0 t1 t2 t3 t4 the cache now serves v1 until the TTL expires — the delete already happened
Nothing failed and nobody wrote buggy code. The reader simply held a value across a window in which the world changed, and then persisted it. Every cache-aside deployment has this race; the only question is how long it can last.

Once you can draw that, the practical patterns stop being folklore and become answers to a specific question — how long can a stale entry survive?

  • TTL. The honest answer. It does not prevent staleness, it bounds it. A 60-second TTL means the worst case above resolves in at most 60 seconds. Most systems are correct because of TTL, not because of clever invalidation, and saying so is a sign of experience rather than a concession.
  • Delete, never update. On write, DEL the key; do not compute the new value and SET it. Two concurrent writers who both SET can land in either order and the loser's value sticks. Two concurrent writers who both DEL converge on "empty", and the next read repopulates from the committed source of truth.
  • Versioned keys. The strongest pattern. Never mutate an entry — put the version in the key: user:42:v17, where 17 comes from a row counter, an ETag, or the row's updated_at. A write bumps the version, so it is writing to a key nobody will ever read again. The race above becomes harmless: A's stale SET lands on v16, which no reader will ever request. Old entries cost memory until LRU reaps them, and that is the entire price.
  • Explicit purge / surrogate keys. What CDNs give you: tag a response product:42 and purge every edge copy carrying that tag in one call. Essential at the CDN layer, but remember purge is itself a distributed system — it takes seconds, and it can fail.
  • CDC-driven invalidation. Tail the database's write-ahead log (Debezium and friends) and emit invalidations from there. This is the only approach where invalidation is derived from committed state, in commit order, with no dual write. It costs you a pipeline; buy it when correctness matters more than simplicity.
The one that dissolves the problem The only cache you never have to invalidate is one whose key contains the identity of its content. Fingerprinted asset URLs, versioned cache keys and content-addressed blobs are all the same trick: make the new value live at a new address, and staleness becomes impossible rather than merely short-lived.
⚠ The layer you cannot take back You can purge Redis in a millisecond and a CDN in ten seconds, but a response you served with Cache-Control: max-age=3600 is sitting in a browser you will never speak to again for the next hour. This is why HTML gets no-cache or a short max-age while fingerprinted JS and CSS get immutable, max-age=31536000. Get that backwards and a bad deploy is unfixable for everyone who loaded the page.

Thundering herd, and the four ways to stop it

A single key serving 50,000 requests a second expires. Recomputing it takes 20 ms. In that window 50,000 × 0.020 = 1,000 requests all miss, all decide independently to recompute, and all hit the database with the same query at the same instant. The database was comfortably serving one query per five minutes for that key; it now gets a thousand at once. Nothing was misconfigured. The cache working correctly produced the outage.

naive coalesced 1,000 requests arrive during the 20 ms recompute window 1,000 requests arrive during the 20 ms recompute window singleflight / lock 1,000 queries 1 query the database sees a 1,000× spike on one key 999 callers await the same promise
The fix is not a bigger database. It is recognising that a thousand identical concurrent questions deserve one answer, and that the cache is the natural place to enforce that.
const inflight = new Map();                    // key → Promise, per process

function coalesce(key, loader) {
  const existing = inflight.get(key);
  if (existing) return existing;               // 999 callers join the same promise

  // finally() matters: on rejection the entry must clear, or one
  // transient error is cached as a permanent failure for that key.
  const p = loader().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}

Be precise about the scope of that fix: it coalesces within one process. With 40 app servers you have gone from 1,000 queries to 40, which is usually enough. If it isn't, the cross-process version is a SET key NX EX 10 lock in Redis — one winner recomputes, the losers either wait briefly or serve the stale value — and you should say out loud that you have just introduced a distributed lock, with the lease expiry and fencing questions that come with it.

TechniqueMechanismCostReach for this when…
Request coalescing / singleflightOne in-flight load per key; everyone else awaits itA few lines; per-process onlyAlways. This is the baseline, not an optimisation
Jittered TTLttl = base ± rand(base × 0.1)One line, no downsideAlways — and specifically whenever many keys are populated together (deploy, warm-up, bulk import)
Early / probabilistic recomputeRefresh before expiry with probability rising as expiry nearsA little duplicate work; needs the last recompute duration storedA handful of extremely hot keys where even one miss is a visible latency spike
Stale-while-revalidateServe the expired value immediately, refresh in the backgroundBounded staleness, by designRead paths that tolerate a few seconds of stale data — which is most read paths
Negative caching + bloom filterCache "does not exist"; or test membership before queryingA short stale window on newly created idsEnumerable keyspaces where a scraper or a bug can request ids that were never real

The probabilistic version is worth naming precisely because it sounds like an invented answer otherwise. XFetch: store how long the last recompute took as delta, and on every read refresh early if now − delta × beta × ln(random()) ≥ expiry, with beta around 1. Expensive-to-compute entries and entries close to expiry refresh sooner, probabilistically, so exactly one unlucky reader usually does the work before anyone has to wait for it.

Hit-rate arithmetic: why 90% is not a good number

This is the section that wins interviews, because almost nobody does the arithmetic. Take a read tier serving 500,000 reads/sec, backed by replicas that each sustain about 8,000 point reads/sec before p99 degrades.

Hit rateMiss rateReads reaching the databaseReplicas required
90%10%500,000 × 0.10 = 50,000/s50,000 ÷ 8,000 = 7
95%5%500,000 × 0.05 = 25,000/s4
99%1%500,000 × 0.01 = 5,000/s1
99.9%0.1%500,000 × 0.001 = 500/s1, at 6% utilisation

Going from 90% to 99% is described in conversation as "nine percentage points". In load terms it is a 10× reduction and it deletes six database replicas. Going the other way is worse: a hit rate slipping from 99% to 98% doubles database load overnight, and no dashboard labelled "cache hit rate 98%" looks alarming. Always reason in miss rate. Hit rate is a vanity metric; miss rate is the thing that is actually multiplied by your traffic.

The same arithmetic explains the latency shape. With a 0.5 ms cache and a 20 ms database:

  • 99% hit: mean = 0.99 × 0.5 + 0.01 × 20 = 0.695 ms
  • 90% hit: mean = 0.90 × 0.5 + 0.10 × 20 = 2.45 ms — 3.5× worse from a number that "sounds fine"
  • And the percentile that matters: at a 10% miss rate, every request above p90 is a cache miss by definition. Your p95 and p99 are not "the cache is a bit slower sometimes" — they are raw database latency, including its tail. A 99% hit rate is what moves the database out of p99 entirely.
⚠ The failure nobody sizes for: losing a cache node Five Redis nodes, 99% hit rate, 5,000 reads/sec reaching the database. One node dies. Its 20% of the keyspace now misses on every request, so the new miss rate is 0.20 + 0.80 × 0.01 = 0.208, and the database receives 500,000 × 0.208 = 104,000 reads/sec — a 20.8× spike against a tier provisioned for 5,000. The database falls over, the cache cannot be refilled, and the outage is now self-sustaining. This is how cache tiers kill databases, and it is why the answers are consistent hashing (so a lost node redistributes rather than reshuffles), load shedding at the origin, and being honest that your database floor is set by your degraded miss rate, not your healthy one.

Sizing follows the same logic in reverse. Hit rate is a function of how much of the working set fits in memory, and real access distributions are Zipf-ish: the top ~20% of keys serve ~80% of requests. That means the first gigabyte buys you an enormous amount and the climb from 90% to 99% can cost 4-8× the memory, because you are now paying to hold the long tail. Knowing where you are on that curve is what makes "should we double the cache?" a calculable question rather than an argument.

Recognizing it in an unseen problem

  • Any prompt with a heavy read:write skew (news feed, product catalogue, URL shortener, profile service) is a caching problem before it is a database problem. State the ratio, then state the miss rate you're targeting and the load that leaves — do not just say "add Redis."
  • A naive design adds a cache and stops there: no TTL policy, no invalidation story, no eviction policy, no stampede protection, and no answer for what happens when a cache node dies. Each of those is a follow-up question the interviewer already has queued.
  • Distinguish caching from replication: a read replica is authoritative and eventually consistent; a cache is non-authoritative and arbitrarily stale. If the prompt needs "must reflect the last write", a replica with read-your-writes routing is the answer, not a cache.
  • Distinguish it from a CDN question: if the payload is large, static and geographically distributed, the win is bandwidth and RTT at the edge, not query offload. Different layer, different invalidation story, same bet.
  • Hot-key language ("a celebrity posts", "a flash sale", "one video goes viral") is the interviewer explicitly asking for stampede handling and hot-key mitigation — coalescing, a per-instance L1 in front of the shared tier, or key splitting.
  • Pitfall: caching writes. If the prompt is write-heavy or the data is read once and never again, a cache adds latency and memory cost for nothing. Say "I would not cache this, and here's why" at least once during a loop — knowing where a cache does not belong reads as strongly as knowing where it does.
←previousDatabases in design↑ CovernextAPIs & communication→