πŸ“š Study Notes / Home / HLD / Session 4
Session 04 Β· Caching 2

Caching, Part 2: how caches decide what to forget β€” and stay correct

In Session 3 we learned what a cache is and why it makes systems fast. Now we go one level deeper. A cache is small, so it must constantly throw old things away (eviction). It holds copies of data, so those copies can drift out of date (consistency). And when the real data changes, the cache must be told (invalidation). We'll build all of it up from scratch, with everyday stories, real pseudocode, and a full case study at the end. Take it slow β€” none of this requires prior knowledge.

⏱ 43 min readπŸ“– 4 topics

1 Eviction mechanisms β€” deciding what to drop


Explain like I'm 5

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.

How do you choose an eviction policy?

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.

The big idea

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.

Homework: locality of reference

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, …] β€” touching a hints b is 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.

Worked example: same requests, different policies

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 D arrives, the use-order was A, B, C, A, B β€” so C is the least recently used. Evict C.
  • LFU: counts are A=2, B=2, C=1 β€” C is least frequent. Evict C.
  • FIFO: A was 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 β†’ node for 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 prev and next pointers) 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.

πŸ”Ž
Look up
hashmap key→node, O(1)
β†’
βœ‚οΈ
Unlink node
fix prev/next, O(1)
β†’
⬆️
Move to head
mark "just used"
β†’
πŸ—‘οΈ
Over capacity?
drop tail = LRU victim
# 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
    }
}
Why two "sentinel" nodes?

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

PolicyEvicts…Bet about the futureCost / bookkeepingBest for
LRULeast recently used"Untouched lately = won't need soon"O(1) with map + linked listGeneral purpose, web/app caches
LFULeast frequently used (lowest count)"Popular stays popular"O(1) possible but more state (counters)Stable long-term popularity
FIFOOldest inserted"Old = expendable" (ignores usage)Tiny β€” just a queueSimple caches, streaming/scan
MRUMost recently used"Just-used = done with it"O(1)One-pass scans of large data
RandomA random itemNo bet β€” pure luckNear-zeroHardware caches, ultra-cheap eviction
TTLAnything past its expiry"Old copies are untrustworthy"Just a timestamp per itemFreshness/consistency (pair with LRU)
Gotcha: hit rate isn't everything

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.

Recap A cache is small, so when it fills up an eviction policy chooses what to drop, betting on what you won't need soon. LRU drops the least-recently-used (and runs O(1) with a hashmap + doubly linked list); LFU drops the least-used-overall; FIFO drops the oldest; MRU drops the newest; Random picks blindly but cheaply; and TTL expires items by time and is usually paired with one of the others.

2 Consistency models for caches


Explain like I'm 5

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):

ModelWhat it meansCan 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.
EventualData becomes consistent eventually. For a while it may be inconsistent, but wait long enough and everything syncs up.Yes, for a bounded window.
No consistencyData loss can happen! No matter how long you wait, the data will never become consistent.Worse β€” the data can be lost entirely.
Where does "No consistency" come from?

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.

The word "staleness"

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.

The core trade-off

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.

Worked example: a product's price

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.

AspectStrong / immediateEventual
Reads after a writeAlways the newest valueMay be stale for a window
SpeedSlower (sync work on every write)Faster (writes don't wait on the cache)
Complexity / costHigher β€” keep cache & DB in lockstepLower β€” let copies drift, fix later
Staleness allowedNoneYes β€” bounded by TTL / propagation time
Use whenMoney, inventory, auth, anything where a wrong read is harmfulLikes, view counts, feeds, recommendations
Trade-off to remember

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.

Recap A cache holds copies, so it needs a consistency model. Strong/immediate consistency means reads are always current (correct but slower and more work). Eventual consistency lets copies be briefly stale (fast and simple, but wrong for a window). TTL is the dial that bounds how stale data can get β€” trading freshness against speed. Pick the model based on how badly a stale read would hurt.

3 Write strategies & invalidation


Explain like I'm 5

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.
Consequence: the cache can go stale

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.

Why doesn't the cache fetch from the DB itself on a miss?

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.

πŸ“¨
1. Read
App asks cache
β†’
❓
2. Hit?
Yes β†’ return it
β†’
πŸ—„οΈ
3. Miss
Read from DB
β†’
πŸ“₯
4. Fill cache
Store for next time
# 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:

KeyValueExpiry
a109:00 am
b209: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.
What consistency does TTL give? Can it serve stale data?

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.
Don't push the expiry forward on reads

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.

Analogy: TTL = fruit with expiry labels in your fridge

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');
Two tricky questions about write-around

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).

Analogy: write-around = restock the fridge on a schedule

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).

✍️
1. Write
App writes to cache
β†’
πŸ—„οΈ
2. & DB
Cache writes DB too
β†’
βœ…
3. Done
Both updated, then ack
# 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"
Why write-through is genuinely hard: atomicity across servers

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.
2-Phase Commit (2PC)

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 is only needed for a global cache

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.

How can there be 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.

So why would anyone use it? Raw speed.

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.

✍️
1. Write
App writes cache only
β†’
⚑
2. Ack fast
Return immediately
β†’
⏳
3. Later
Flush to DB async
# 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

StrategyWhat it does on a writeWrite speedConsistencyMain riskUse when
Write-throughWrite cache + DB together, synchronouslySlower (two writes)Strong β€” cache never staleSlower writes; caches data that may never be readReads must always be current (e.g. balances)
Write-back / behindWrite cache now, flush to DB laterFastestWeak until flushLost writes if cache dies before flushWrite-heavy, can tolerate small loss risk (metrics, counters)
Write-aroundWrite DB only, skip cacheFastFine, but cold cacheRead-after-write is a guaranteed missWritten data rarely re-read soon (logs, archives)
Cache-asideWrite DB, then invalidate (delete) cached copyFastStrong-ish if invalidation is reliableStale reads if a delete is missed; thundering-herd on popular missesDefault for most read-heavy systems
The two classic pitfalls

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.

Why "delete" beats "update" on invalidation

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.

Recap A write strategy decides what happens to the cache on a write. Cache-aside (lazy loading) is the default: read from cache, fall back to DB on a miss, and invalidate (delete) the key on writes. Write-through updates cache and DB together (strong, but slower). Write-back updates the cache and flushes the DB later (fastest, but can lose writes). Write-around skips the cache on writes (good when new data isn't read soon). The two pitfalls to fear are stale reads and lost writes.

4 Case study β€” Scaler Code Judge


Explain like I'm 5

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:

DataRead frequencyChange frequencyCache it?Why
Problem statementsExtremely high (every visitor)Very rare (occasional edit)βœ… Yes, aggressivelyClassic read-heavy, write-rare β€” ideal for caching
Test casesHigh (every submission runs them)Rare (author updates)βœ… YesReused across thousands of submissions; expensive to refetch
Verdicts (per submission)Moderate (user re-checks result)Never (a verdict is final)βœ… Yes β€” immutable, easyOnce computed, a verdict for a given submission never changes
Leaderboard / countsHighConstant (every submission)⚠️ Yes, with short TTLFine to be a few seconds stale (eventual consistency)
User's live submission in progressLowN/A❌ NoUnique, 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.
The key insight

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

πŸ‘€
User opens problem
cache-aside: hit β†’ instant; miss β†’ load + fill (LRU+TTL)
β†’
πŸ“€
User submits code
judge fetches test cases from cache (hit)
β†’
βš™οΈ
Run & judge
compute verdict against tests
β†’
πŸ’Ύ
Store verdict
write-through: DB + cache (immutable)
β†’
πŸ“Š
Update board
short-TTL eventual consistency
Worked example: an author fixes a broken test case

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.

Pitfalls specific to a judge

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.

Recap In the Scaler Code Judge we cache problem statements and test cases (read-heavy, write-rare) and verdicts (immutable, cache forever). We use cache-aside reads with LRU + TTL eviction, accept eventual consistency for the leaderboard, but demand strong consistency (immediate invalidation) for test cases β€” because a stale test case produces a wrong verdict. The design is really just Sections 1–3 applied data-type by data-type.

β˜… 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

Papers, docs & deep dives