Target Level: Junior β Senior β Staff Engineer Duration: 60-75 minutes Interview Focus: Cache eviction strategies, O(1) data structure design, distributed systems
Interview Importance: π΄ Critical β LRU Cache is the #1 most-asked data structure problem in FAANG interviews. LFU and distributed caching appear in every system design round. Understanding these is non-negotiable for senior+ roles.
A cache is a fast, limited-size storage layer that keeps frequently or recently used data close to the consumer, avoiding expensive recomputation or network calls.
The Problem: Caches have finite memory. When full, we must decide which item to remove β this is the eviction policy.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Cache (limited) β
β β
β βββββ βββββ βββββ βββββ βββββ β
β β A β β B β β C β β D β β E β β Cache full! β
β βββββ βββββ βββββ βββββ βββββ β
β β
β New item F arrives β WHO gets evicted? β
β β
β LRU says: "Evict whoever was used LEAST RECENTLY" β
β LFU says: "Evict whoever was used LEAST FREQUENTLY" β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Real-World Analogy:
Evict the item that was accessed longest ago. Every get or put marks an item as "just used" and moves it to the front. The item at the back is the eviction candidate.
Loading diagramβ¦
Two data structures working together:
| Structure | Purpose | Time Complexity |
|---|---|---|
| HashMap | O(1) key lookup to find nodes | O(1) get/put |
| Doubly Linked List | O(1) insertion/deletion to track recency order | O(1) move/remove |
class Node { constructor(key, value) { this.key = key; this.value = value; this.prev = null; this.next = null; } } class LRUCache { constructor(capacity) { this.capacity = capacity; this.map = new Map(); // Dummy head and tail to avoid null checks this.head = new Node(0, 0); this.tail = new Node(0, 0); this.head.next = this.tail; this.tail.prev = this.head; } // Move node right after head (most recently used position) _moveToHead(node) { this._removeNode(node); this._addToHead(node); } _addToHead(node) { node.prev = this.head; node.next = this.head.next; this.head.next.prev = node; this.head.next = node; } _removeNode(node) { node.prev.next = node.next; node.next.prev = node.prev; } // Remove and return the node just before tail (least recently used) _popTail() { const node = this.tail.prev; this._removeNode(node); return node; } get(key) { if (!this.map.has(key)) return -1; const node = this.map.get(key); this._moveToHead(node); // Mark as recently used return node.value; } put(key, value) { if (this.map.has(key)) { const node = this.map.get(key); node.value = value; this._moveToHead(node); return; } const newNode = new Node(key, value); this.map.set(key, newNode); this._addToHead(newNode); if (this.map.size > this.capacity) { const evicted = this._popTail(); this.map.delete(evicted.key); } } }
LRUCache(3) β capacity = 3
Operation List (HeadβTail) Map Result
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
put(1, "A") [1:A] {1βnode} β
put(2, "B") [2:B, 1:A] {1βnode, 2βnode} β
put(3, "C") [3:C, 2:B, 1:A] {1βnode, 2βnode, 3βn} β
get(1) [1:A, 3:C, 2:B] {1βnode, 2βnode, 3βn} "A"
β 1 moved to head
put(4, "D") [4:D, 1:A, 3:C] {1βnode, 3βnode, 4βn} β
β 2:B evicted (LRU) key 2 deleted
get(2) [4:D, 1:A, 3:C] {1βnode, 3βnode, 4βn} -1 (miss!)
| Operation | Time | Space |
|---|---|---|
get(key) | O(1) | β |
put(key, value) | O(1) | β |
| Overall space | β | O(capacity) |
Evict the item with the lowest access count. If there's a tie, evict the least recently used among them (LRU as tiebreaker).
Loading diagramβ¦
Three data structures:
| Structure | Purpose |
|---|---|
| keyMap (HashMap) | Key β Node (with value + frequency) |
| freqMap (HashMap) | Frequency β Doubly Linked List of nodes at that frequency |
| minFreq (integer) | Tracks the current minimum frequency for O(1) eviction |
class LFUNode { constructor(key, value) { this.key = key; this.value = value; this.freq = 1; this.prev = null; this.next = null; } } class DoublyLinkedList { constructor() { this.head = new LFUNode(0, 0); this.tail = new LFUNode(0, 0); this.head.next = this.tail; this.tail.prev = this.head; this.size = 0; } addToHead(node) { node.prev = this.head; node.next = this.head.next; this.head.next.prev = node; this.head.next = node; this.size++; } removeNode(node) { node.prev.next = node.next; node.next.prev = node.prev; this.size--; } removeTail() { if (this.size === 0) return null; const node = this.tail.prev; this.removeNode(node); return node; } } class LFUCache { constructor(capacity) { this.capacity = capacity; this.keyMap = new Map(); // key β LFUNode this.freqMap = new Map(); // freq β DoublyLinkedList this.minFreq = 0; } _getFreqList(freq) { if (!this.freqMap.has(freq)) { this.freqMap.set(freq, new DoublyLinkedList()); } return this.freqMap.get(freq); } // Bump a node's frequency: remove from old list, add to new list _updateFreq(node) { const oldFreq = node.freq; const oldList = this.freqMap.get(oldFreq); oldList.removeNode(node); // If old frequency list is now empty and was the minimum, bump minFreq if (oldList.size === 0) { this.freqMap.delete(oldFreq); if (this.minFreq === oldFreq) this.minFreq++; } node.freq++; const newList = this._getFreqList(node.freq); newList.addToHead(node); } get(key) { if (!this.keyMap.has(key)) return -1; const node = this.keyMap.get(key); this._updateFreq(node); // Increase access frequency return node.value; } put(key, value) { if (this.capacity === 0) return; if (this.keyMap.has(key)) { const node = this.keyMap.get(key); node.value = value; this._updateFreq(node); return; } // Evict if at capacity if (this.keyMap.size >= this.capacity) { const minList = this.freqMap.get(this.minFreq); const evicted = minList.removeTail(); // LRU among min-freq items this.keyMap.delete(evicted.key); if (minList.size === 0) this.freqMap.delete(this.minFreq); } const newNode = new LFUNode(key, value); this.keyMap.set(key, newNode); const freqOneList = this._getFreqList(1); freqOneList.addToHead(newNode); this.minFreq = 1; // New node always starts at freq=1 } }
LFUCache(3) β capacity = 3
Operation keyMap freqMap minFreq Result
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
put(1,"A") {1β(A,f=1)} {1: [1]} 1 β
put(2,"B") {1β(A,f=1), 2β(B,f=1)} {1: [2,1]} 1 β
put(3,"C") {1,2,3} {1: [3,2,1]} 1 β
get(1) {1β(A,f=2), 2,3} {1: [3,2], 2: [1]} 1 "A"
β freq bumped to 2
get(2) {1βf=2, 2βf=2, 3βf=1} {1: [3], 2: [2,1]} 1 "B"
put(4,"D") {1βf=2, 2βf=2, 4βf=1} {1: [4], 2: [2,1]} 1 β
β key=3 evicted (freq=1, LRU in that bucket)
get(3) β β β -1 (miss!)
| Aspect | LRU | LFU |
|---|---|---|
| Eviction criterion | Least recently accessed | Least frequently accessed |
| Best for | Temporal locality (recent = likely needed again) | Frequency-based access patterns |
| Time complexity | O(1) get/put | O(1) get/put |
| Space complexity | O(n) β HashMap + DLL | O(n) β 2 HashMaps + multiple DLLs |
| Implementation | Simpler (1 HashMap + 1 DLL) | More complex (2 HashMaps + freq buckets) |
| Cache pollution | Vulnerable to sequential scans | Resistant to one-off accesses |
| Cold-start problem | No issue | New items start at freq=1, easily evicted |
| Stale data risk | Low β old items naturally evicted | High β popular-but-stale items persist |
| Real-world use | Redis, Memcached, OS page cache | CDN edge caching, DNS resolvers |
Loading diagramβ¦
// 1. API Response Cache β Avoid redundant network calls const apiCache = new LRUCache(50); const fetchWithCache = async (url) => { const cached = apiCache.get(url); if (cached !== -1) return cached; const response = await fetch(url); const data = await response.json(); apiCache.put(url, data); return data; }; // 2. Image/Asset Preloading Cache const imageCache = new LRUCache(100); const loadImage = (src) => { if (imageCache.get(src) !== -1) return imageCache.get(src); const img = new Image(); img.src = src; imageCache.put(src, img); return img; }; // 3. React Component Memoization (conceptually how React.memo works) const renderCache = new LRUCache(200); const memoizedRender = (componentKey, props) => { const cacheKey = `${componentKey}:${JSON.stringify(props)}`; const cached = renderCache.get(cacheKey); if (cached !== -1) return cached; const result = expensiveRender(componentKey, props); renderCache.put(cacheKey, result); return result; };
| Application | Cache Type | Why |
|---|---|---|
| Redis | Approximate LRU/LFU (sampling) | Fast eviction without global recency bookkeeping |
| Database query cache | LRU | Recent queries likely repeated |
| CDN edge servers | LFU | Popular assets should persist |
| DNS resolvers | LFU | Frequently queried domains stay cached |
| OS page/buffer cache | LRU variant (Clock) | Recently accessed pages likely re-accessed |
| CPU L1/L2/L3 cache | LRU approximation | Hardware-level temporal locality |
| Connection pools | LRU | Recycle idle connections |
Real caches expire stale data. Here's LRU with TTL:
class LRUCacheWithTTL extends LRUCache { constructor(capacity, defaultTTL = 60000) { super(capacity); this.defaultTTL = defaultTTL; } put(key, value, ttl = this.defaultTTL) { super.put(key, value); const node = this.map.get(key); node.expiry = Date.now() + ttl; } get(key) { if (!this.map.has(key)) return -1; const node = this.map.get(key); // Check expiry before returning if (node.expiry && Date.now() > node.expiry) { this._removeNode(node); this.map.delete(key); return -1; } this._moveToHead(node); return node.value; } }
In multi-threaded environments (not JS, but critical for system design interviews):
// Conceptual β JavaScript is single-threaded, but this pattern matters // for system design discussions involving Java/Go/C++ caches class ThreadSafeLRUCache { constructor(capacity, shardCount = 16) { // Shard the cache to reduce lock contention this.shards = Array.from( { length: shardCount }, () => new LRUCache(Math.ceil(capacity / shardCount)) ); this.shardCount = shardCount; } _getShard(key) { // Simple hash to distribute keys across shards const hash = [...String(key)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0); return this.shards[Math.abs(hash) % this.shardCount]; } get(key) { return this._getShard(key).get(key); } put(key, value) { this._getShard(key).put(key, value); } }
Single-node caches hit limits:
| Limit | Solution |
|---|---|
| Memory ceiling | Spread data across multiple nodes |
| Single point of failure | Replication for availability |
| Network latency | Place caches near users (geo-distribution) |
| Hot key bottleneck | Shard hot keys across replicas |
Loading diagramβ¦
When nodes join or leave, consistent hashing minimizes key redistribution.
class ConsistentHashRing { constructor(nodes = [], virtualNodes = 150) { this.ring = new Map(); // hash position β node this.sortedKeys = []; // sorted hash positions this.virtualNodes = virtualNodes; nodes.forEach((node) => this.addNode(node)); } _hash(key) { let hash = 0; for (let i = 0; i < key.length; i++) { hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; } return Math.abs(hash); } addNode(node) { // Virtual nodes spread each physical node across the ring for (let i = 0; i < this.virtualNodes; i++) { const virtualKey = `${node}#${i}`; const hash = this._hash(virtualKey); this.ring.set(hash, node); this.sortedKeys.push(hash); } this.sortedKeys.sort((a, b) => a - b); } removeNode(node) { for (let i = 0; i < this.virtualNodes; i++) { const hash = this._hash(`${node}#${i}`); this.ring.delete(hash); this.sortedKeys = this.sortedKeys.filter((k) => k !== hash); } } // Find which node owns a given key getNode(key) { if (this.sortedKeys.length === 0) return null; const hash = this._hash(key); // Find the first node position >= hash (clockwise on the ring) for (const pos of this.sortedKeys) { if (pos >= hash) return this.ring.get(pos); } return this.ring.get(this.sortedKeys[0]); // Wrap around } } // Usage const ring = new ConsistentHashRing(["cache-1", "cache-2", "cache-3"]); console.log(ring.getNode("user:1234")); // β "cache-2" console.log(ring.getNode("session:567")); // β "cache-1" // Adding a node only moves ~1/N keys ring.addNode("cache-4");
Loading diagramβ¦
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Partitioning | Range-based | Consistent hashing | Consistent hashing β uniform distribution, graceful scaling |
| Replication | Synchronous | Asynchronous | Async β lower latency; accept brief inconsistency |
| Eviction scope | Global LRU | Per-node LRU | Per-node β simpler, no cross-node coordination |
| Cache-aside vs Write-through | App manages cache | Cache writes to DB | Cache-aside β more flexible, app controls invalidation |
| Hot key handling | Single node | Replicate to N nodes | Replicate hot keys β distribute read load |
Node failure detected (heartbeat timeout):
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Health checker detects Node B is down β
β 2. Hash ring removes Node B β
β 3. Node B's key range redistributes to Node C β
β 4. Replica B' promoted (if available) β
β 5. Cold keys cause cache misses β refill from DB β
β 6. Node B recovers β re-added, keys rebalance β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
Cache stampede prevention:
βββββββββββββββββββββββββββββ
β’ Jittered expiry: TTL + random(0, TTL*0.1)
β’ Lock-based refresh: Only one thread fetches from DB
β’ Stale-while-revalidate: Serve stale, refresh async
class DistributedLRUNode { constructor(capacity) { this.cache = new LRUCacheWithTTL(capacity); this.pendingFetches = new Map(); // Prevent duplicate DB calls } async getOrFetch(key, fetchFn) { const cached = this.cache.get(key); if (cached !== -1) return cached; // Deduplicate concurrent requests for the same key if (this.pendingFetches.has(key)) { return this.pendingFetches.get(key); } const fetchPromise = fetchFn(key).then((value) => { // Jittered TTL to prevent synchronized expiry const jitter = Math.random() * 5000; this.cache.put(key, value, 60000 + jitter); this.pendingFetches.delete(key); return value; }); this.pendingFetches.set(key, fetchPromise); return fetchPromise; } }
In production, strict textbook LRU is often too expensive. Most high-scale caches use approximate/relaxed policies that are "good enough" for hit ratio but much cheaper operationally.
Redis does not maintain a perfectly sorted global recency list of every key.
When eviction is needed:
This is intentionally approximate, but very fast and memory-efficient.
| Concern | Strict LRU | Relaxed/Approximate LRU |
|---|---|---|
| CPU cost on reads | High: global recency updates on every hit | Low: avoids hot global coordination |
| Contention/locking | Higher under very high QPS | Lower, better parallelism |
| Memory overhead | More metadata for exact ordering | Smaller metadata footprint |
| Real-world hit ratio | Theoretical optimum for recency-only model | Usually close enough with much better throughput |
The key idea: approximate policies still converge toward protecting hot keys and evicting cold keys over time.
Large systems often use multiple queues to separate one-hit noise from genuinely hot items:
This avoids a common LRU issue where a one-time scan pollutes the cache.
At very high throughput, even "touching metadata" per hit can be expensive. Some systems decouple hit recording:
Trade-off: metadata is briefly stale, but the critical read path stays much faster.
The state-of-the-art cache policy used in production systems:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β W-TinyLFU β
β β
β ββββββββββββ ββββββββββββββββββββ βββββββββββββββ β
β β Window βββββββ TinyLFU Filter βββββββ Main Cache β β
β β (LRU, 1%)β β (admission gate) β β (SLRU, 99%) β β
β ββββββββββββ ββββββββββββββββββββ βββββββββββββββ β
β β
β New items enter window. To enter main cache, they must β
β beat the eviction candidate's estimated frequency. β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Policy | Hit Ratio | Scan Resistance | Implementation |
|---|---|---|---|
| LRU | Good | β Poor | Simple |
| LFU | Good | β Strong | Moderate |
| ARC | Very Good | β Strong | Complex (patented) |
| W-TinyLFU | Best | β Strong | Complex |
Used in PostgreSQL's buffer cache:
ββββββββββββββββββββββββββββββββββββββββββββ
β A1in (FIFO) β Am (LRU, promoted) β
β First access Second+ access β
β β
β Evict from A1in first (one-hit wonders) β
β Am holds genuinely popular pages β
ββββββββββββββββββββββββββββββββββββββββββββ
Q1: Design an LRU Cache with O(1) get and put. Use a HashMap for O(1) lookup combined with a Doubly Linked List for O(1) insertion/deletion. The map stores keyβnode references. On access, move the node to the head. On eviction, remove from the tail. Sentinel head/tail nodes eliminate null-pointer edge cases.
Q2: Why can't we use a single sorted structure (like a BST) for LRU? A BST gives O(log n) for insertion/deletion/lookup. We need O(1). The HashMap+DLL combo achieves this because the map provides O(1) access to the DLL node, and DLL provides O(1) move/remove when you have a direct pointer to the node.
Q3: How does LFU handle the "frequency starvation" problem? Old popular items accumulate high frequency counts and never get evicted, even when they're no longer accessed. Solutions: (1) Decay/aging β periodically halve all frequencies, (2) Time-windowed frequency β only count accesses in the last N minutes, (3) W-TinyLFU β use a Count-Min Sketch that resets periodically.
Q4: How would you scale an LRU cache to handle 1M QPS? (1) Shard the cache across multiple nodes using consistent hashing. (2) Replicate hot keys to multiple nodes. (3) Use per-shard LRU (no global coordination). (4) Add a local L1 cache on each app server (small, fast) in front of the distributed L2 cache. (5) Use async replication to avoid write latency.
Q5: What's the difference between cache-aside, write-through, and write-back?
| Pattern | Read | Write | Trade-off |
|---|---|---|---|
| Cache-aside | App checks cache β miss β fetch DB β populate cache | App writes DB, invalidates cache | Simple, but risk of stale reads |
| Write-through | Read from cache | Write cache + DB synchronously | Consistent, but higher write latency |
| Write-back | Read from cache | Write cache only, async flush to DB | Fast writes, risk of data loss on crash |
Q6: What is a cache stampede and how do you prevent it? When a popular cache key expires, hundreds of concurrent requests all miss the cache and hit the database simultaneously. Prevention: (1) Mutex/lock β only one request fetches, others wait. (2) Stale-while-revalidate β serve expired data while refreshing async. (3) Jittered TTL β randomize expiry to avoid synchronized expiration. (4) Early refresh β refresh before actual expiry (probabilistic early expiration).
Q7: Does Redis implement perfect LRU? No. Redis uses approximate eviction via sampling. During eviction it samples a small candidate set and evicts the worst candidate (least recently used / least frequently used in that set). This avoids expensive global ordering updates on every access while retaining strong practical hit ratio.
// β BAD: O(n) to remove/reorder elements class BadLRU { constructor(capacity) { this.capacity = capacity; this.items = []; // Array-based β shifting is O(n)! } get(key) { const idx = this.items.findIndex((i) => i.key === key); // O(n) search if (idx === -1) return -1; const [item] = this.items.splice(idx, 1); // O(n) shift this.items.unshift(item); // O(n) shift return item.value; } }
// β GOOD: O(1) with HashMap + Doubly Linked List // Use the LRUCache implementation from Section 2
// β BAD: Memory leak β map grows unbounded put(key, value) { const newNode = new Node(key, value); this._addToHead(newNode); this.map.set(key, newNode); if (this.map.size > this.capacity) { this._popTail(); // Removed from list but NOT from map! } }
// β GOOD: Always clean up both data structures put(key, value) { const newNode = new Node(key, value); this._addToHead(newNode); this.map.set(key, newNode); if (this.map.size > this.capacity) { const evicted = this._popTail(); this.map.delete(evicted.key); // Clean up the map too! } }
// β BAD: Null checks everywhere _addToHead(node) { if (this.head === null) { this.head = node; this.tail = node; } else { node.next = this.head; this.head.prev = node; this.head = node; } }
// β GOOD: Sentinel (dummy) nodes eliminate edge cases constructor() { this.head = new Node(0, 0); // dummy this.tail = new Node(0, 0); // dummy this.head.next = this.tail; this.tail.prev = this.head; } _addToHead(node) { // No null checks needed β head.next always exists node.prev = this.head; node.next = this.head.next; this.head.next.prev = node; this.head.next = node; }
// β BAD: Creates duplicate nodes for same key put(key, value) { const newNode = new Node(key, value); this.map.set(key, newNode); // Old node still in linked list! this._addToHead(newNode); }
// β GOOD: Update existing node in-place put(key, value) { if (this.map.has(key)) { const existing = this.map.get(key); existing.value = value; // Update value this._moveToHead(existing); // Refresh position return; } // Only create new node for new keys const newNode = new Node(key, value); this.map.set(key, newNode); this._addToHead(newNode); }
| Algorithm | get() | put() | Space | Eviction Logic |
|---|---|---|---|---|
| LRU | O(1) | O(1) | O(n) | Remove tail of DLL |
| LFU | O(1) | O(1) | O(n) | Remove tail of min-freq DLL |
| Distributed LRU | O(1) + network RTT | O(1) + network RTT | O(n) per node | Per-node LRU |
| W-TinyLFU | O(1) | O(1) | O(n) | Frequency-based admission filter |
| Level | What You Learn | Key Concepts |
|---|---|---|
| π’ Junior | LRU/LFU basics, HashMap+DLL | O(1) operations, eviction policies, sentinel nodes |
| π‘ Senior | Production features, thread safety | TTL, sharding, cache stampede, write policies |
| π΄ Staff | Distributed caching, advanced policies | Consistent hashing, replication, W-TinyLFU, failure handling |
Test your understanding with 3 quick questions