1 Eviction mechanisms β deciding what to drop
Imagine your school backpack can only hold 5 books, but you own 50. Every time you grab a new book to carry, if the bag is full you have to take one out and leave it at home. Which one do you leave behind? Maybe the one you haven't touched in ages. Maybe the one you almost never read. A cache is exactly that small backpack, and an eviction rule is your way of choosing which book to leave behind when a new one needs to fit.
Recall from Session 3 that a cache is a small, fast store that keeps copies of the data we use most. The word "small" is the whole point: fast memory (like RAM) is expensive, so a cache only has room for a tiny slice of all your data. When the cache is full and a new item needs to come in, something already inside must go. Choosing what to remove is called eviction, and the rule that makes the choice is an eviction policy (sometimes called a replacement policy).
Why a cache is (almost) always full
When you first start a cache server, it is empty. But an empty cache is wasted cache β you want it holding as much useful data as possible. So as requests flow in, the cache fills upβ¦ and from that point onwards it stays full (until it crashes and restarts). Once it's full but you still want to cache more data, you must first kick something out to make room. That is the entire job of an eviction policy: decide which already-cached item to "kick out" to make space for new data.
Just use LRU. For 99% of use cases you don't need to think at all β LRU works well enough. It might not be perfect for your workload, but it will be fine. Only if you have lots of spare time and have already optimised everything else should you implement several strategies, run them on real traffic, and measure which performs best. The rule is: never guess β measure.
A cache wins only when the things it keeps are the things you're about to ask for again. So a good eviction policy is really a prediction of the future: "which item am I least likely to need soon?" Throw that one out. Every policy below is just a different guess about the future.
Two words you'll hear constantly
- Cache hit β you asked for something and it was already in the cache (fast! good!).
- Cache miss β it wasn't there, so you had to fetch it from the slow source and (usually) put it in the cache, evicting something if full.
The goal of every eviction policy is to maximise the hit rate β the percentage of requests that are hits.
The policies, one at a time
LRU β Least Recently Used
LRU evicts the item that hasn't been touched for the longest time. The bet: "if you haven't used it in a while, you probably won't need it soon." This is the most popular policy in real systems (the right choice in ~99% of cases) because it matches how people actually use data β recently used things tend to be used again.
LRU works because of locality. Read about these two ideas:
- Temporal locality β if some data has been accessed right now, it is highly likely to be accessed again in the near future. (This is exactly why LRU keeps recently-used items.)
- Spatial locality β if some data has been accessed right now, then
nearby data is highly likely to be accessed soon. (e.g. iterating over an array
[a, b, c, d, f, e, g, r, β¦]β touchingahintsbis next.)
LFU β Least Frequently Used
LFU evicts the item that has been used the fewest times overall. It keeps a counter per item. The bet: "popular things stay popular." Great for items with a stable, long-term popularity (a viral video everyone keeps watching), but it can keep stale "once-famous" items around too long and is slow to let go of yesterday's hits.
An O(1) LFU keeps a frequency bucket for each count: each bucket is itself an ordered
list of keys with that exact frequency. We track the min_freq bucket so the victim
is found instantly; on a tie within a bucket we evict the least-recently-used key (insertion order).
# LFU cache: O(1) get/put with frequency buckets; LRU tie-break within a bucket from collections import defaultdict, OrderedDict class LFUCache: def __init__(self, capacity): self.cap = capacity self.val = {} # key -> value self.freq = {} # key -> use count self.buckets = defaultdict(OrderedDict) # count -> keys (oldest first) self.min_freq = 0 def _bump(self, key): # move key to its next-higher bucket f = self.freq[key] del self.buckets[f][key] if not self.buckets[f] and f == self.min_freq: self.min_freq += 1 self.freq[key] = f + 1 self.buckets[f + 1][key] = None def get(self, key): if key not in self.val: return -1 # cache miss self._bump(key) return self.val[key] def put(self, key, value): if self.cap == 0: return if key in self.val: # update + bump frequency self.val[key] = value self._bump(key) return if len(self.val) >= self.cap: # evict least-frequent (oldest within bucket) evict, _ = self.buckets[self.min_freq].popitem(last=False) del self.val[evict]; del self.freq[evict] self.val[key] = value self.freq[key] = 1 self.buckets[1][key] = None self.min_freq = 1 # --- demo --- c = LFUCache(2) c.put(1, 10); c.put(2, 20) print(c.get(1)) # 10 (freq of 1 -> 2, freq of 2 stays 1) c.put(3, 30) # full -> evicts key 2 (least frequent) print(c.get(2)) # -1 (evicted) print(c.get(3)) # 30
// LFU cache: O(1) get/put with frequency buckets; LRU tie-break within a bucket. #include <iostream> #include <list> #include <unordered_map> using namespace std; class LFUCache { int cap, minFreq = 0; unordered_map<int, pair<int, int>> val; // key -> {value, freq} unordered_map<int, list<int>> buckets; // freq -> keys (front = oldest) unordered_map<int, list<int>::iterator> pos; // key -> node in its bucket void bump(int key) { // move key to next-higher bucket int f = val[key].second; buckets[f].erase(pos[key]); if (buckets[f].empty() && f == minFreq) minFreq++; val[key].second = f + 1; buckets[f + 1].push_back(key); pos[key] = prev(buckets[f + 1].end()); } public: LFUCache(int capacity) : cap(capacity) {} int get(int key) { if (!val.count(key)) return -1; // cache miss bump(key); return val[key].first; } void put(int key, int value) { if (cap == 0) return; if (val.count(key)) { val[key].first = value; bump(key); return; } if ((int)val.size() >= cap) { // evict least-frequent, oldest first int evict = buckets[minFreq].front(); buckets[minFreq].pop_front(); val.erase(evict); pos.erase(evict); } val[key] = {value, 1}; buckets[1].push_back(key); pos[key] = prev(buckets[1].end()); minFreq = 1; } }; int main() { LFUCache c(2); c.put(1, 10); c.put(2, 20); cout << c.get(1) << "\n"; // 10 c.put(3, 30); // evicts key 2 (least frequent) cout << c.get(2) << "\n"; // -1 cout << c.get(3) << "\n"; // 30 }
// LFU cache: O(1) get/put with frequency buckets; LRU tie-break within a bucket. import java.util.*; class LFUCache { private final int cap; private int minFreq = 0; private final Map<Integer, Integer> val = new HashMap<>(); // key -> value private final Map<Integer, Integer> freq = new HashMap<>(); // key -> count // freq -> keys, insertion-ordered so the eldest is the LRU tie-break private final Map<Integer, LinkedHashSet<Integer>> buckets = new HashMap<>(); public LFUCache(int capacity) { this.cap = capacity; } private void bump(int key) { // move key to next-higher bucket int f = freq.get(key); buckets.get(f).remove(key); if (buckets.get(f).isEmpty() && f == minFreq) minFreq++; freq.put(key, f + 1); buckets.computeIfAbsent(f + 1, k -> new LinkedHashSet<>()).add(key); } public int get(int key) { if (!val.containsKey(key)) return -1; // cache miss bump(key); return val.get(key); } public void put(int key, int value) { if (cap == 0) return; if (val.containsKey(key)) { val.put(key, value); bump(key); return; } if (val.size() >= cap) { // evict least-frequent, eldest first LinkedHashSet<Integer> b = buckets.get(minFreq); int evict = b.iterator().next(); b.remove(evict); val.remove(evict); freq.remove(evict); } val.put(key, value); freq.put(key, 1); buckets.computeIfAbsent(1, k -> new LinkedHashSet<>()).add(key); minFreq = 1; } public static void main(String[] args) { LFUCache c = new LFUCache(2); c.put(1, 10); c.put(2, 20); System.out.println(c.get(1)); // 10 c.put(3, 30); // evicts key 2 (least frequent) System.out.println(c.get(2)); // -1 System.out.println(c.get(3)); // 30 } }
FIFO β First In, First Out
FIFO evicts whatever was added first, regardless of how often or how recently it's been used β like a queue at a ticket counter. Simple and cheap, but dumb: it might evict a super-popular item just because it happens to be old.
MRU β Most Recently Used
MRU is the complete opposite of LRU: it evicts the item used most recently. Sounds backwards, and it is bad for 99% of cases β but it shines in specific patterns, e.g. scanning a big file once top-to-bottom, where the page you just read is the one you're least likely to read again soon. Homework: find a valid use-case of the MRU eviction policy.
FIFO vs LIFO
Alongside FIFO there's LIFO (Last In, First Out) β evict whatever was added most recently, like a stack. Both ignore usage entirely; they are simple but rarely the best choice. In practice the field narrows to FIFO, LIFO, LRU, LFU, and MRU, and for ~99% of cases the answer is LRU.
Random
Random replacement just picks a victim at random. No bookkeeping at all. It sounds careless, but it's extremely cheap and, surprisingly, performs almost as well as LRU in some hardware caches β which is why CPUs sometimes use it.
TTL-based β Time To Live
A TTL (Time To Live) isn't a victim-selection rule like the others; it's an expiry stamp. Each item is tagged "good until 3:05pm." After that, it's treated as gone no matter how popular it is. TTL is usually combined with another policy: TTL handles "too old to trust," while LRU/LFU handle "no room left." We'll lean on TTL heavily in the consistency and case-study sections.
Cache size = 3. Requests arrive in this order:
A B C A D B. Let's see what each policy evicts when D
arrives and the cache is full of {A, B, C}.
- LRU: at the moment
Darrives, the use-order was A, B, C, A, B β soCis the least recently used. Evict C. - LFU: counts are A=2, B=2, C=1 β
Cis least frequent. Evict C. - FIFO:
Awas inserted first. Evict A (even though A was just used again β FIFO doesn't care!). - MRU: the most recently used item was
B. Evict B.
Same workload, four different victims. That difference is exactly why choosing the right policy matters.
Implementing LRU: hashmap + doubly linked list
LRU needs two things to be fast: instant lookup ("is X in the cache?") and instant reordering ("mark X as just-used" and "find the oldest item"). The classic trick combines two data structures so that every operation is O(1):
- A hashmap from
key β nodefor O(1) lookup. - A doubly linked list ordered by recency: the head is the
most-recently-used end, the tail is the least-recently-used end. A doubly linked node
(with
prevandnextpointers) lets us unlink and move a node in O(1) without scanning.
On a get, we look the key up in the map and move its node to the head (just used). On a put, we add at the head; if we're over capacity, we drop the tail node (the oldest) and remove it from the map.
# LRU cache: O(1) get and put using hashmap + doubly linked list class Node: def __init__(self, key, value): self.key = key self.value = value self.prev = None # toward head (most recent) self.next = None # toward tail (least recent) class LRUCache: def __init__(self, capacity): self.cap = capacity self.map = {} # key -> Node, gives O(1) lookup # two dummy "sentinel" nodes so we never check for None edges self.head = Node(None, None) # most-recently-used side self.tail = Node(None, None) # least-recently-used side self.head.next = self.tail self.tail.prev = self.head def _remove(self, node): # unlink a node, O(1) node.prev.next = node.next node.next.prev = node.prev def _add_front(self, node): # insert right after head, O(1) node.next = self.head.next node.prev = self.head self.head.next.prev = node self.head.next = node def get(self, key): if key not in self.map: return -1 # cache miss node = self.map[key] self._remove(node) # pull it out... self._add_front(node) # ...and mark as most-recently-used return node.value def put(self, key, value): if key in self.map: # update existing self._remove(self.map[key]) node = Node(key, value) self.map[key] = node self._add_front(node) if len(self.map) > self.cap: # over capacity -> evict LRU lru = self.tail.prev # node just before tail = oldest self._remove(lru) del self.map[lru.key] # --- demo --- c = LRUCache(2) c.put(1, 10); c.put(2, 20) print(c.get(1)) # 10 (1 is now most-recently-used) c.put(3, 30) # full -> evicts key 2 (least recent) print(c.get(2)) # -1 (miss, was evicted) print(c.get(3)) # 30
// LRU cache: O(1) get and put using unordered_map + doubly linked list. // std::list is a doubly linked list; we store iterators for O(1) splice. #include <iostream> #include <list> #include <unordered_map> using namespace std; class LRUCache { int cap; list<pair<int, int>> dll; // front = most recent, back = least recent unordered_map<int, list<pair<int, int>>::iterator> map; // key -> node public: LRUCache(int capacity) : cap(capacity) {} int get(int key) { auto it = map.find(key); if (it == map.end()) return -1; // cache miss dll.splice(dll.begin(), dll, it->second); // move node to front, O(1) return it->second->second; } void put(int key, int value) { auto it = map.find(key); if (it != map.end()) dll.erase(it->second); // drop old node dll.push_front({key, value}); // insert at most-recent end map[key] = dll.begin(); if ((int)map.size() > cap) { // over capacity -> evict LRU map.erase(dll.back().first); dll.pop_back(); // back = least recent } } }; int main() { LRUCache c(2); c.put(1, 10); c.put(2, 20); cout << c.get(1) << "\n"; // 10 c.put(3, 30); // evicts key 2 cout << c.get(2) << "\n"; // -1 cout << c.get(3) << "\n"; // 30 }
// LRU cache: O(1) get and put by extending LinkedHashMap (map + DLL built in) import java.util.LinkedHashMap; import java.util.Map; class LRUCache extends LinkedHashMap<Integer, Integer> { private final int cap; public LRUCache(int capacity) { // accessOrder=true: get/put move an entry to the most-recent end super(capacity, 0.75f, true); this.cap = capacity; } // LinkedHashMap calls this after each insert; true -> drop eldest (LRU) @Override protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) { return size() > cap; } public int get(int key) { return getOrDefault(key, -1); // -1 on a cache miss } public static void main(String[] args) { LRUCache c = new LRUCache(2); c.put(1, 10); c.put(2, 20); System.out.println(c.get(1)); // 10 c.put(3, 30); // evicts key 2 System.out.println(c.get(2)); // -1 System.out.println(c.get(3)); // 30 } }
The dummy head and tail nodes never hold real data.
They exist so that inserting and removing never hit a None edge case β the list
is "never empty." It's a small trick that removes a pile of if first/last checks
and bugs. LFU is similar but harder: you also keep a count per node and group nodes by
frequency, so it's typically a hashmap of keys plus a hashmap of frequency-buckets.
Comparison table
| Policy | Evicts⦠| Bet about the future | Cost / bookkeeping | Best for |
|---|---|---|---|---|
| LRU | Least recently used | "Untouched lately = won't need soon" | O(1) with map + linked list | General purpose, web/app caches |
| LFU | Least frequently used (lowest count) | "Popular stays popular" | O(1) possible but more state (counters) | Stable long-term popularity |
| FIFO | Oldest inserted | "Old = expendable" (ignores usage) | Tiny β just a queue | Simple caches, streaming/scan |
| MRU | Most recently used | "Just-used = done with it" | O(1) | One-pass scans of large data |
| Random | A random item | No bet β pure luck | Near-zero | Hardware caches, ultra-cheap eviction |
| TTL | Anything past its expiry | "Old copies are untrustworthy" | Just a timestamp per item | Freshness/consistency (pair with LRU) |
A fancy policy with a higher hit rate can still lose if its bookkeeping is slow or memory-hungry. That's why Random and FIFO survive in real hardware: being "good enough" but nearly free can beat being "optimal" but expensive. Always weigh hit-rate gains against the cost of tracking usage.
2 Consistency models for caches
Imagine the teacher writes the homework on the board (that's the real answer), and you copy it into your notebook (that's your cache). Now the teacher changes the homework. Two things can happen. Either someone tells everyone immediately to fix their notebooks β now every notebook always matches the board. Or word spreads slowly, so for a little while some kids still have the old homework in their notebook. The first way is always-correct but more work; the second way is faster but means a few wrong copies float around for a bit.
A cache holds a copy of data that really "lives" somewhere else (the database, the source of truth). The moment we keep a copy, we face a question: when the original changes, how quickly must the copy agree with it? The answer is the cache's consistency model.
First, two plain words: consistent vs inconsistent
- Consistent β it is not possible to read a stale value. Every read is guaranteed to return the latest data.
- Inconsistent β it is possible for reads to be stale (you might read an old value).
With that, there are three consistency models you'll hear about (we cover two in depth here; the third is the scary one):
| Model | What it means | Can you read stale data? |
|---|---|---|
| Immediate (strong) | Data is consistent immediately β no waiting. All copies sync up at once. | Never β impossible to read a stale value. |
| Eventual | Data becomes consistent eventually. For a while it may be inconsistent, but wait long enough and everything syncs up. | Yes, for a bounded window. |
| No consistency | Data loss can happen! No matter how long you wait, the data will never become consistent. | Worse β the data can be lost entirely. |
It isn't a deliberate choice you'd make for important data β it's the property of the write-back strategy (Section 3). Because writes live only in the volatile cache until a later flush, a crash can wipe them permanently. We'll return to it there. (More on consistency in general comes in future classes.)
Strong (immediate) consistency
Strong consistency (also called immediate consistency) means: the instant the real data changes, every read β from cache or database β sees the new value. There is never a moment where someone reads an outdated copy. To guarantee this, the cache and database must be kept in lockstep on every write (e.g. update both together, or wipe the cached copy the moment the data changes β we'll see exactly how in Section 3).
Eventual consistency
Eventual consistency means: after a change, copies will all agree eventually β but for a short window, some readers may still get the old value. As long as no new writes come in, given enough time everything converges to the latest value. The gap between "data changed" and "all copies updated" is when staleness lives.
Staleness is how out-of-date a cached copy is. A value that's "5 seconds stale" was correct 5 seconds ago but the real data has since changed. Eventual consistency accepts some staleness in exchange for speed; strong consistency refuses to allow any.
Strong consistency gives you always-correct reads but costs speed and effort (every write must touch the cache too, and reads may have to wait). Eventual consistency gives you blazing speed but accepts a window of wrong answers. There is no free lunch β you pick based on how much a stale read would actually hurt.
How TTL gives you "tunable" eventual consistency
Recall TTL from Section 1. A TTL is the dial that controls how stale data can get. If you cache
a value with TTL = 60s, the cache promises the copy is at most 60 seconds old. So
the worst-case staleness equals the TTL. Shorter TTL β fresher data but more cache misses (more trips to
the slow database); longer TTL β faster but staler. The TTL knob lets you trade freshness for
speed.
An online store caches a product's price.
- Strong consistency: the seller changes the price from $20 β $25. Every customer immediately sees $25. Nobody can ever buy at the old price. Correct, but every price change forces the cache to update right away, and reads can't be served until that's done.
- Eventual (TTL = 60s): after the change, customers might still see $20 for up to 60 seconds until the cached copy expires and gets refreshed to $25. Much faster and simpler β but for up to a minute, some customers see a stale price.
Which is right? For a price, a 60-second stale window might be unacceptable (legal/financial). For a "number of likes" counter, who cares if it's a few seconds behind? The data's importance picks the model.
| Aspect | Strong / immediate | Eventual |
|---|---|---|
| Reads after a write | Always the newest value | May be stale for a window |
| Speed | Slower (sync work on every write) | Faster (writes don't wait on the cache) |
| Complexity / cost | Higher β keep cache & DB in lockstep | Lower β let copies drift, fix later |
| Staleness allowed | None | Yes β bounded by TTL / propagation time |
| Use when | Money, inventory, auth, anything where a wrong read is harmful | Likes, view counts, feeds, recommendations |
You can't have "always correct," "always fast," and "always simple" all at once. Picking a consistency model is really picking how much staleness your users can tolerate. Most real systems use eventual consistency with a sensible TTL for most data, and reserve strong consistency for the few fields that truly must never be wrong.
3 Write strategies & invalidation
You keep a sticky note on your fridge with your friend's phone number (the cache), and the real number lives in your phone's contacts (the database). When the number changes, what do you do? You could update both the sticky note and the contact at the same time. Or update the sticky note now and fix the real contact later. Or just throw the sticky note away and write a fresh one only the next time you actually need to call. Each of those is a real "write strategy" for keeping a cache and its source in sync.
Sections 1 and 2 set up the problem: cached copies drift from the truth, and we must decide how fresh to keep them. A write strategy is the concrete rule for what happens to the cache when data is written or changed. Closely related is cache invalidation β marking a cached copy as no longer valid so it won't be served. (There's a famous joke: "There are only two hard things in computer science: cache invalidation and naming things." It's hard because getting it wrong means serving wrong data.)
How cache reads & writes actually happen
Before naming strategies, here's the typical read/write cycle that most caches use:
- Writes go directly to the database (typically) β not to the cache.
- Reads: the app server first checks the cache.
- Cache hit (value present) β return the cached value.
- Cache miss (value absent) β app server fetches the value from the DB, returns it to the user, and (asynchronously) updates the value in the cache.
Since writes only go to the DB, the copy inside the cache can become stale. Suppose the
DB now holds a = 20 but the cache still says a = 10. A
read will be a cache hit, so the app server happily returns 10 β the
wrong value β and never even checks the database. This is precisely the problem invalidation exists to
solve.
You might ask: on a miss, why can't the cache just go to the DB, store the value, and return it β instead of bouncing back to the app server? Some people do build it this way, but it's typically considered bad design (an anti-pattern). The reason is separation of concerns:
- All the business logic β which data to fetch, which tables to join, how to filter, validate, and sort β lives in the app server.
- The cache server is meant to be "dumb" β just fast key/value storage.
- If you make the cache "intelligent," your business logic is now split across two codebases that can drift out of sync (one becomes stale), and the cache server would need a powerful CPU to do all that slicing and dicing.
So all fetching/updating responsibility stays with the app server; the cache just acts as dumb storage.
Two reading patterns first
How data gets into the cache also matters. Two read patterns:
- Cache-aside (lazy loading) β the application talks to the cache itself. On a miss, the app loads from the DB and then fills the cache. The cache only ever holds things that were actually requested.
- Read-through β the app only ever talks to the cache; the cache itself knows how to fetch from the DB on a miss. (Mentioned for completeness; cache-aside is by far the most common.)
Cache-aside (lazy loading) β the default workhorse
This is the most common pattern in real systems. Reads are "lazy": data is loaded into the cache only when first requested.
# Cache-aside read (lazy loading) def get_user(user_id): user = cache.get(user_id) if user is not None: return user # cache hit user = db.query(user_id) # cache miss -> go to source of truth cache.set(user_id, user, ttl=300) # fill cache for next time (5-min TTL) return user
On a write with cache-aside, you update the DB and then invalidate (delete) the cached copy, so the next read re-loads the fresh value:
# Cache-aside write: update DB, then invalidate the stale copy def update_user(user_id, new_data): db.update(user_id, new_data) # source of truth changes first cache.delete(user_id) # invalidate -> next read reloads fresh
TTL (Time To Live) β the simplest invalidation policy
TTL is both an invalidation policy and (with a small modification) an eviction consideration β you may even see it listed as an eviction strategy because it's so simple. (Strictly, pure TTL is not an eviction policy on its own.)
The idea: whenever you store data in the cache, alongside the value you also store an expiry time. Every entry is valid for a fixed amount of time (its TTL). TTL can be set globally (one value for all keys) or per key. After the TTL expires, we assume the value is stale and invalidate it β even though in reality we have no idea whether the DB value actually changed.
For example, if we write a = 10 at 8 am with a TTL of
1 hour, the entry's expiry is set to 9 am:
| Key | Value | Expiry |
|---|---|---|
| a | 10 | 9:00 am |
| b | 20 | 9:05 am |
When do we actually delete an expired entry? Eager vs Lazy
- Eager invalidation β delete the entry immediately when its expiry passes. This forces the cache to run a timer/event loop per expiry (think Timer + Priority Queue), adding overhead. Worse, if many entries share an expiry time (say 9 am), then at 9 am incoming requests must wait while the cache busily clears all the expired entries. Performance becomes "jittery" β not smooth β with temporary latency spikes during cleanup.
- Lazy invalidation β delete the entry only when the next read for that key arrives and finds it expired. You may temporarily store stale data (wasted space), but that's fine because the eviction algorithm reclaims it anyway. Lazy is almost always better: it gives smooth latency across all requests.
Yes, TTL can serve stale data. If the DB is updated before the cache entry expires, every read in that window returns the old cached value (a hit on not-yet-expired data). Because the data does become correct once the entry expires and reloads, TTL provides eventual consistency.
What's the ideal TTL value?
Completely application-dependent β anywhere from 5 seconds to a week.
- Lower TTL β more cache misses (data invalidated quickly), but data is fresher.
- Higher TTL β better hit rate, but data is staler.
Tempting idea: bump an entry's expiry every time it's read. Don't β that breaks TTL.
Suppose you set a at 8:55 with expiry 9:00. The DB is updated at 8:58 (cache now
stale), so it should be invalidated at 9:00. But a read arrives at 8:59 and you push expiry to 9:05 β now
your stale data lives until 9:05. You may push the expiry forward when you write to the cache, but
pushing it on reads turns TTL into something else entirely.
When you buy fruit, before putting it in the fridge you label each one with an expiry date. When you're hungry you check the fridge and read the tag:
- If the expiry date has passed β throw the fruit in the garbage (even if it looks fine) and go to the market to buy fresh.
- If the expiry date has not passed β eat it (even if it's actually rotten).
You trust the label, not the real freshness β exactly like TTL trusts the expiry stamp, not the DB.
Write-around β a background job recomputes the cache
Write-around is very similar to TTL in that it also provides eventual consistency, but it's used for a different reason. The key difference is how expensive it is to produce the cached value:
- TTL works best when fetching an entry from the DB is easy and needs little
post-processing. Example β caching user preferences:
-- table: user_preferences(user_id bigint, name text, preferences json) SELECT * FROM user_preferences WHERE user_id = 1234;
A very simple, fast query. If we're OK with eventual consistency, TTL is ideal here. - Write-around works best when producing the data is extremely expensive or
needs heavy computation. Example β "find all users using dark theme on a mobile device":
-- expensive join + group-by SELECT * FROM user_preferences up JOIN user_devices ud ON ud.user_id = up.user_id WHERE ud.device_type = 'mobile' AND up.theme = 'dark' GROUP BY up.user_id;
A complex, slow query β here a write-around policy is better.
With write-around there's a separate background process (running in its own app server) that periodically fetches all recently updated data from the DB, does the heavy computation, and updates the cache in one go:
# background "cron job": recompute and refresh the cache in bulk SELECT * FROM ... WHERE updated_at > (now() - '1 hour');
Can it serve stale data? Yes. Until the background cron job runs again, any DB updates mean the cache holds stale data β hence eventual consistency.
What if the data isn't in the cache yet (the cron hasn't run once)? Reads always check the cache; if the computed data isn't there, we return a 404 to the client β we do NOT go to the DB. Why? Because write-around is used precisely when the computation is expensive, so we'd rather wait for the background job to compute it once than run that expensive computation on the fly for every incoming request.
What if the data is too large to fit in the cache? Write-around is typically used when the computed result is small enough to fit. An eviction algorithm usually isn't needed here β you evict only when the data is no longer required (e.g. drop the leaderboard once the contest is over).
You buy fruit and just put it in the fridge (no labels). You only go to the market at random times. When hungry, you check the fridge: if the fruit is there you eat it (fresh or rotten); if it's not there you sleep hungry β you do not rush to the market. Every weekend you simply throw out everything in the fridge (fresh or rotten) and restock. The periodic restock is the cron job.
Write-through β update cache and DB together, synchronously
Write-through means every write goes through the cache to the DB in the same operation: write the cache and the DB before reporting success. The cache is never out of date, so it naturally supports strong consistency β at the cost of slower writes (you pay for two writes every time).
# Write-through: both updated before we return success def write_through(key, value): cache.set(key, value) # 1) update cache db.write(key, value) # 2) update DB, synchronously return "ok" # only now is the write "done"
We sometimes require immediate (strong) consistency β no stale reads, ever. The only way is to make every write land at both the cache and the DB together. But ensuring atomicity across two separate servers is difficult:
- Single server (shared RAM, OS thread synchronisation β locks, semaphores, mutexes): it's easy to make two tasks happen atomically (either both or neither).
- Multiple servers (cache & DB, no shared RAM): what if the DB write succeeds but the cache write fails? You must either retry the cache write (user waits β high latency) or roll back the DB write (a transaction). What if the DB and cache writes both succeed but the cache's response is lost? The app server thinks it failed and tries to roll back β what if the rollback fails? What if the ACK for the rollback fails? It cascades.
The protocol used to perform atomic transactions in a distributed setting is Two-Phase Commit. It's powerful but costly: slow (high latency, low throughput), hard to implement (needs state management), and needs extra infrastructure. We'll formalise this trade-off in the CAP theorem class. The blunt takeaway: if you want consistency, you pay for it in blood β high latency and low throughput.
Optional readings: "Distributed Systems 7.1: Two-phase commit"; "Distributed Transactions are Hard (How Two-Phase Commit works)"; "Two-Phase Commit Protocol"; and an implementation guide on building distributed transactions with 2PC.
2PC matters when the cache is global (separate from the app server), because the app server must write in two places β DB and cache. But if the cache is local (in the app server's own RAM/HDD) and writes come to the app servers, you can always use immediate consistency (write-through) with no 2PC: there are no latency issues because a local write is guaranteed to succeed, so you effectively only coordinate with the database write (one state, not two).
Write-back (write-behind) β update cache now, DB later
Write-back (a.k.a. write-behind) writes only to the cache immediately and returns success right away; the DB is updated later, asynchronously (in a batch or after a short delay). It provides no consistency guarantees β in fact it can lead to data loss.
If the cache server crashes with unsynced changes, that data is permanently lost. The cache is volatile (it doesn't persist to disk); the DB is persistent, but the new writes never reached it before the crash.
Every read/write happens at the cache, so it's insanely fast β 100,000+ reads/writes per second, versus a database that handles only roughly 100β1000 writes/second per server. Use write-back when:
- individual data points are not important;
- it's OK to lose some data;
- trends matter more than individual data points (analytics);
- extremely high write throughput is required.
Classic examples: counting views / likes / clicks on a video or post. This is the source of the "No consistency" model from Section 2.
# Write-back: ack immediately, flush to DB asynchronously def write_back(key, value): cache.set(key, value) # update cache now dirty_queue.push(key) # mark "needs flushing to DB" return "ok" # return BEFORE the DB is updated # a background worker drains the queue periodically def flush_worker(): while True: key = dirty_queue.pop() # risk: a crash here loses un-flushed writes db.write(key, cache.get(key))
Write-around β write straight to the DB, skip the cache
Write-around writes new data only to the DB and does not put it in the cache. The cache is populated only later, on a read miss (often paired with cache-aside reads). Good when freshly written data is rarely read again soon β it avoids flooding the cache with write-only data. The downside: a read right after a write is a guaranteed miss (the new data isn't cached yet).
Putting the write strategies side by side
| Strategy | What it does on a write | Write speed | Consistency | Main risk | Use when |
|---|---|---|---|---|---|
| Write-through | Write cache + DB together, synchronously | Slower (two writes) | Strong β cache never stale | Slower writes; caches data that may never be read | Reads must always be current (e.g. balances) |
| Write-back / behind | Write cache now, flush to DB later | Fastest | Weak until flush | Lost writes if cache dies before flush | Write-heavy, can tolerate small loss risk (metrics, counters) |
| Write-around | Write DB only, skip cache | Fast | Fine, but cold cache | Read-after-write is a guaranteed miss | Written data rarely re-read soon (logs, archives) |
| Cache-aside | Write DB, then invalidate (delete) cached copy | Fast | Strong-ish if invalidation is reliable | Stale reads if a delete is missed; thundering-herd on popular misses | Default for most read-heavy systems |
1. Stale reads. If a write updates the DB but the cache keeps an old copy (a missed or
failed invalidation), readers get wrong data until the TTL saves them. This is the bug cache-aside must
guard against β always delete (or update) the cached key on a write.
2. Lost writes. Write-back's nightmare: an acknowledged write that only lived in the
cache vanishes if the cache crashes before flushing. Never use plain write-back for data you can't afford
to lose.
On a write, many systems prefer to delete the cached key rather than overwrite it with the new value. Deleting is simpler and avoids a subtle race: if two writes update the cache out of order, you could leave a stale value behind. Delete-then-reload-on-next-read sidesteps that. The cost is one extra cache miss after each write β usually a fine trade.
4 Case study β Scaler Code Judge
Picture a coding competition. There's a question sheet everyone reads, a hidden answer key the judges use to grade, and a scoreboard showing who got it right. Thousands of students read the same question sheet at once β it would be silly to print a fresh sheet for every single student from a faraway printer. Instead, you keep stacks of pre-printed sheets nearby (a cache!). But the moment a teacher fixes a typo in the question, you must throw away the old stacks so nobody reads the wrong sheet. That's the whole game here, applied to a real system.
Let's design caching for a code judge β an online-judge system (think Scaler, LeetCode, Codeforces) where a user reads a problem, submits code, and the system runs that code against hidden test cases to produce a verdict like "Accepted" or "Wrong Answer." It's read-heavy and latency-sensitive β a perfect caching playground that lets us apply everything from Sections 1β3.
Step 1 β What should we cache?
The first design question is always what data is read far more often than it changes? Those are the caching candidates. Let's classify the judge's data:
| Data | Read frequency | Change frequency | Cache it? | Why |
|---|---|---|---|---|
| Problem statements | Extremely high (every visitor) | Very rare (occasional edit) | β Yes, aggressively | Classic read-heavy, write-rare β ideal for caching |
| Test cases | High (every submission runs them) | Rare (author updates) | β Yes | Reused across thousands of submissions; expensive to refetch |
| Verdicts (per submission) | Moderate (user re-checks result) | Never (a verdict is final) | β Yes β immutable, easy | Once computed, a verdict for a given submission never changes |
| Leaderboard / counts | High | Constant (every submission) | β οΈ Yes, with short TTL | Fine to be a few seconds stale (eventual consistency) |
| User's live submission in progress | Low | N/A | β No | Unique, single-use β caching gives nothing |
Step 2 β What consistency does each need?
Now apply Section 2. Each data type tolerates a different amount of staleness:
- Problem statements: mostly eventual consistency is fine (a long TTL, say minutes to hours). But when an author fixes a wrong example or a typo that changes the answer, we must invalidate immediately β a stale statement could make a correct solution look wrong.
- Test cases: this is the strict one. If a buggy test case is fixed, every later submission must be judged against the new set. A stale test case = wrong verdicts. So we want strong consistency: invalidate the moment they change.
- Verdicts: trivially consistent because they're immutable β a verdict for submission #12345 is computed once and never changes. Immutable data is the easiest thing in the world to cache: cache it forever, never invalidate.
- Leaderboard: eventual consistency with a short TTL (e.g. 5β10s) is perfect β nobody is harmed if the ranking is a few seconds behind.
One system, several different caching strategies β chosen per data type by asking two questions: how often is it read vs. changed? (Section 1) and how much does a stale read hurt? (Section 2). There is no single right answer for "the cache"; you mix and match.
Step 3 β Which write strategy and eviction policy?
- Reads: use cache-aside (Section 3) everywhere β on a miss, load from the DB and fill the cache. Simple and robust.
- Problem statements & test cases: on an author edit, do a cache-aside write: update the DB, then invalidate (delete) the cached key so the next read reloads the fresh version. For test cases we make this invalidation reliable (strong consistency) so no submission is judged on stale tests.
- Verdicts: write-through is natural and safe β when a verdict is computed, store it in both DB and cache. Since it's immutable, there's never an invalidation to worry about.
- Eviction: LRU for problem statements and test cases (popular problems stay hot, old contest problems fade out naturally). Add a TTL as a safety net so nothing lingers wrong forever. LFU would also work well for problems, since a handful of famous problems are read constantly.
Step 4 β Walk through the design end to end
Problem #88 has a wrong hidden test case, so correct solutions are getting "Wrong Answer." The author fixes it. Here's the read and the invalidation in pseudocode:
# --- Read path (cache-aside) used by the judge for every submission --- def get_test_cases(problem_id): tests = cache.get(f"tests:{problem_id}") if tests is not None: return tests # hit: thousands of submissions reuse this tests = db.get_test_cases(problem_id) # miss: load from source of truth cache.set(f"tests:{problem_id}", tests, ttl=3600) # LRU + 1h TTL safety net return tests # --- Author edits the test cases: strong consistency required --- def update_test_cases(problem_id, new_tests): db.set_test_cases(problem_id, new_tests) # 1) source of truth first cache.delete(f"tests:{problem_id}") # 2) invalidate immediately cache.delete(f"problem:{problem_id}") # 3) statement may reference them too # next submission misses, reloads fresh tests -> no more wrong verdicts
Because we delete (not overwrite) and we delete after the DB write, the very next submission is guaranteed to judge against the corrected tests. Old, already-final verdicts stay as they were (immutable) β re-judging old submissions, if desired, is a separate batch job.
Stale test cases = wrong verdicts, which is far worse than a slightly stale leaderboard β so test-case invalidation must be reliable, not best-effort. And beware the thundering herd: if a wildly popular problem's cache entry expires during a contest, thousands of submissions miss at once and stampede the DB. Mitigate with longer TTLs for hot problems, pre-warming the cache before a contest, or a lock so only one request reloads while others wait.
β Putting it all together
You've now gone from "what is a cache" (Session 3) to "how do real caches stay small and correct." Here's the one-paragraph story tying the four topics together:
A cache is small, so when it fills it runs an eviction policy β usually LRU (O(1) with a hashmap + doubly linked list), sometimes LFU, FIFO, MRU, or Random, often with a TTL stamp. Because a cache holds copies, it needs a consistency model: strong (always current, slower) or eventual (fast, briefly stale, with TTL as the dial). When data changes, a write strategy keeps things in sync β cache-aside with invalidation by default, or write-through (strong, slow), write-back (fast, risks lost writes), or write-around β each guarding against stale reads and lost writes. Put it all together in the Scaler Code Judge and you pick a different mix per data type, because the right cache design is never one rule β it's the right rule for each piece of data.
Quick self-check
What two data structures make LRU O(1), and what is each for?
A hashmap (key β node) for O(1) lookup, and a doubly linked list ordered by recency so you can move a node to the head (just-used) or drop the tail (least-recently-used) in O(1) without scanning.
Cache holds {A, B, C} (size 3), use-order was A B C A B, now D arrives. What does LRU evict vs FIFO?
LRU evicts C (least recently used). FIFO evicts A (first inserted), even though A was just used again β FIFO ignores usage.
What's the core trade-off between strong and eventual consistency?
Strong/immediate = always-correct reads but slower and more work on every write. Eventual = fast and simple but allows a window of stale reads. You choose based on how much a stale read hurts; TTL lets you bound the staleness.
Which write strategy risks losing acknowledged writes, and why?
Write-back (write-behind). It acknowledges the write after updating only the cache and flushes to the DB later; if the cache crashes before flushing, those writes are lost.
In cache-aside, why delete the cached key on a write instead of overwriting it?
Deleting is simpler and avoids a race where two out-of-order writes leave a stale value behind. The next read just reloads the fresh value from the DB β at the cost of one extra miss.
In the code judge, why must test cases use strong consistency but the leaderboard can be eventual?
A stale test case produces a wrong verdict (real harm), so it must be invalidated immediately. A leaderboard that's a few seconds behind harms no one, so eventual consistency with a short TTL is fine.
π References & Further Reading
Class material
- π Original class notes / handout (Google Doc) β open the shared class material for this session.
- Class handout: "[SST-2028] - Caching 2".
Papers, docs & deep dives
- AWS: Caching best practices β cache-aside, write-through and other write strategies explained.
- Redis: Key eviction policies β LRU, LFU, TTL and how a real cache evicts under memory pressure.
- MDN: HTTP caching β freshness, expiry and cache invalidation in practice.