LRU Cache
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ ฟ๏ธ A parking lot with 3 spots. The last car parked is closest to the exit. When full, the car furthest from the exit gets towed.
The mental model
- Park a new car โ it goes to spot #1 (MRU). Everyone else shifts back one. If the lot is full, the car in the last spot (LRU) gets towed first.
- Retrieve your car (GET) โ it gets re-parked at spot #1 (MRU). You used it, so it's "recently used" again. Everyone else shifts back.
- Car not found (MISS) โ you ask for a car that was towed (or never parked). You get back nothing (โ1). The lot is unchanged.
Why this works in real systems
Most programs have temporal locality: if you accessed a piece of data recently, you are likely to access it again soon. LRU exploits this by keeping recently-used items in fast storage (cache) and evicting the ones that haven't been touched in a while.
Where you encounter LRU in the wild
- CPU L1/L2/L3 cache lines (hardware-level)
- Browser page cache (
Cache-Control: max-age) - Redis eviction policy (
maxmemory-policy allkeys-lru) - Database buffer pool (PostgreSQL shared_buffers)
- CDN edge nodes โ evict cold content to serve hot content faster
Interactive Sandbox
โ Move something, see it react instantly.Cache (capacity=3)
1โnode1
key
1
val=10
MRU
empty
#2
empty
LRU
Operations
Pattern insight
The cache is a live-ordered list. Reads are not passive.
โ ๏ธ Gotcha: Every GET moves the item to MRU โ order always reflects recency.
Challenge
Explore all 5 patterns to understand every LRU behavior. (1/5 explored)
Why Should I Care?
โ The exact interview question + the bug it kills.Q: Why does doubly-linked list + hashmap give O(1)?
A hashmap alone gives O(1) lookup, but moving a node to the front of a singly-linked list requires traversal โ O(n). A doubly-linked list lets you unlink a node in O(1) (you have the prev pointer), then insert it at the head in O(1). The hashmap stores key โ node, so you jump directly to the node without any traversal.
| 1 | // The "aha": both get and put are O(1) |
| 2 | // get(key): |
| 3 | // 1. hashmap.get(key) โ O(1) โ node |
| 4 | // 2. unlink(node) โ O(1) โ uses node.prev + node.next |
| 5 | // 3. insertAtHead(node)โ O(1) โ update head/sentinel pointers |
| 6 | // |
| 7 | // put(key, val): |
| 8 | // Same as get, plus: |
| 9 | // 4. if full: remove tail โ O(1) (tail sentinel gives direct access) |
| 10 | // hashmap.delete(tail.key) โ O(1) |
Q: What is the difference between LRU and LFU?
LRU (Least Recently Used) evicts the item that was accessed furthest back in time, regardless of how many times it was accessed.
LFU (Least Frequently Used) evicts the item with the lowest access count, regardless of when it was last accessed.
LFU is better when some keys are permanently hot (e.g., a homepage). LRU is better for sequential scans where you want to avoid cache pollution from one-time-reads.
Q: When is LRU a bad cache policy?
- Sequential scans (cache thrashing): a full-table scan reads every row once, evicting genuinely hot rows. Postgres uses a "clock sweep" to detect scan patterns and avoid this.
- Periodic batch jobs: a nightly job that touches all keys resets the LRU order, making the cache useless for the daytime workload that follows.
- Skewed frequency: if 10% of keys are accessed 99% of the time, LFU outperforms LRU because those hot keys never get evicted no matter how recently others were touched.
๐ Common bug: not updating order on cache hit
The most common LRU implementation mistake: implementing get as a pure read without moving the node to the front.
| 1 | // โ WRONG โ returns value but silently corrupts eviction order |
| 2 | get(key) { |
| 3 | return this.map.has(key) ? this.map.get(key).val : -1; |
| 4 | // ^^ missing: move node to head of the DLL |
| 5 | } |
| 6 | ย |
| 7 | // โ CORRECT โ O(1) lookup AND correct order maintenance |
| 8 | get(key) { |
| 9 | if (!this.map.has(key)) return -1; |
| 10 | const node = this.map.get(key)!; |
| 11 | this.moveToHead(node); // โ the part most people forget |
| 12 | return node.val; |
| 13 | } |
This bug is subtle: the cache still returns correct values. It only fails eviction correctness, which is invisible until you stress-test with a sequence that should keep a specific key alive.
The Deep Dive
โ Spec refs, engine internals, the minutiae.Full TypeScript implementation
| 1 | class DLLNode { |
| 2 | key: number; val: number; |
| 3 | prev: DLLNode | null = null; |
| 4 | next: DLLNode | null = null; |
| 5 | constructor(k: number, v: number) { this.key = k; this.val = v; } |
| 6 | } |
| 7 | ย |
| 8 | class LRUCache { |
| 9 | private cap: number; |
| 10 | private map = new Map<number, DLLNode>(); |
| 11 | // Sentinels: head.next = MRU, tail.prev = LRU |
| 12 | private head = new DLLNode(0, 0); |
| 13 | private tail = new DLLNode(0, 0); |
| 14 | ย |
| 15 | constructor(capacity: number) { |
| 16 | this.cap = capacity; |
| 17 | this.head.next = this.tail; |
| 18 | this.tail.prev = this.head; |
| 19 | } |
| 20 | ย |
| 21 | get(key: number): number { |
| 22 | if (!this.map.has(key)) return -1; |
| 23 | const node = this.map.get(key)!; |
| 24 | this.remove(node); |
| 25 | this.insertFront(node); |
| 26 | return node.val; |
| 27 | } |
| 28 | ย |
| 29 | put(key: number, val: number): void { |
| 30 | if (this.map.has(key)) this.remove(this.map.get(key)!); |
| 31 | const node = new DLLNode(key, val); |
| 32 | this.insertFront(node); |
| 33 | this.map.set(key, node); |
| 34 | if (this.map.size > this.cap) { |
| 35 | const lru = this.tail.prev!; |
| 36 | this.remove(lru); |
| 37 | this.map.delete(lru.key); |
| 38 | } |
| 39 | } |
| 40 | ย |
| 41 | private remove(node: DLLNode) { |
| 42 | node.prev!.next = node.next; |
| 43 | node.next!.prev = node.prev; |
| 44 | } |
| 45 | ย |
| 46 | private insertFront(node: DLLNode) { |
| 47 | node.next = this.head.next; |
| 48 | node.prev = this.head; |
| 49 | this.head.next!.prev = node; |
| 50 | this.head.next = node; |
| 51 | } |
| 52 | } |
| 53 | // Time: O(1) get + O(1) put ยท Space: O(capacity) |
LFU implementation (frequency buckets)
LFU requires tracking frequency per key. The trick is maintaining a min-frequency pointer and a Map<freq, DoublyLinkedList>. On access, move the node from freq bucket N to N+1. On eviction, remove the LRU node from the min-freq bucket. Still O(1) amortized.
LFU is harder to implement correctly (off-by-one on min-freq pointer) and is less commonly asked at interviews than LRU, but appears at senior/staff level.
Cache-oblivious algorithms
Cache-oblivious algorithms are designed to be optimal without knowing the cache size. They exploit spatial/temporal locality naturally โ e.g., recursive merge sort has better cache behavior than iterative merge sort because the recursion naturally fits sub-problems into L1/L2 cache. LRU is the typical eviction policy assumed for cache-oblivious analysis.
Redis LRU/LFU modes
Redis does not maintain a true LRU list (it would be too expensive for millions of keys). Instead it uses probabilistic LRU: on eviction, it samples 5 random keys and evicts the one with the oldest access time. This is maxmemory-policy allkeys-lru.
Redis 4.0+ added LFU mode (allkeys-lfu). It approximates frequency using a Morris counter (logarithmic increment probability) to fit frequency into 8 bits per key.
Browser cache (HTTP)
Cache-Control: max-age=3600โ fresh for 1 hour, then stalestale-while-revalidate=60โ serve stale for 60s while revalidating in background (async LRU-like pattern)stale-if-error=86400โ serve stale for 1 day if origin is down- Browser disk cache uses a variant of LRU/LFU hybrid; Chrome uses a scored eviction that weights recency, frequency, and resource size.
Related patterns
- Doubly Linked List โ the DLL pattern alone: O(1) insert/remove at any position
- HashMap + DLL โ the structural heart of LRU; reusable for ordered-map needs
- Monotonic Deque โ sliding window max/min in O(1); similar "order maintenance" idea
- Skip List โ probabilistic sorted structure; Redis sorted sets use this
Interview Questions
โ Real questions from real interviews โ with answers.HashMap gives O(1) lookup; doubly linked list gives O(1) reordering without scanning.
LRU evicts the least recently used key; LFU evicts the least frequently used โ and breaks ties by recency.
Wrap all get/put in a mutex; or use a concurrent hashmap with lock striping to reduce contention.
Maintain a secondary min-stack alongside the LRU โ each node stores the current minimum at insertion time.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is clock/CLOCK cache replacement algorithm, and how does it relate to LRU?