LRU Cache (Design)
Advanced · 14 min read · ▶ live playground · ✦ checkpoint
“Design a least-recently-used (LRU) cache.” The prompt fits in one sentence, but the design only works when two data structures preserve one ordering invariant after every read and write. This case study builds that class model first; the coding-judge version comes later.
Requirements
Fix the eviction contract and complexity target before choosing a container. A cache that returns the right values but scans on every access has missed half the prompt.
| Kind | Requirement for this design |
|---|---|
| Functional | The cache has a fixed positive capacity chosen at construction. |
| Functional | get(key) returns the stored value when present and marks that entry most-recently used. |
| Functional | get(key) returns undefined when the key is absent. |
| Functional | put(key, value) inserts a new entry as most-recently used. |
| Functional | put() on an existing key updates its value and marks it most-recently used. |
| Functional | When an insertion takes the cache over capacity, it evicts exactly the least-recently-used entry. |
| Non-functional | get() runs in O(1) average time. |
| Non-functional | put(), including recency movement and eviction, runs in O(1) average time. |
| Non-functional | The cache preserves one entry per key and one matching node in recency order. |
TTL expiry, persistence, distributed-cache behavior, and thread-safety internals are out of scope. They are useful design-review extensions, but adding them to the first model would hide the data-structure invariant the interview is asking for.
Clarifying questions to ask
Each answer changes either the public contract or the structure needed behind it.
- Is capacity fixed or resizable? Fixed for this design. The constructor rejects zero and negative values. Resizing later would need a rule for immediate evictions when the new capacity is smaller than the current size.
- Does
get()count as a use? Yes. A successful read moves that entry to the most-recently-used end. A miss changes nothing. - Is eviction strictly LRU? Yes. We are not approximating recency and we are not counting frequency. LFU, FIFO, random replacement, and segmented policies solve different workloads.
- Is
O(1)required or only desirable? Required on average for both operations. A linear search or shift fails the contract even when it is short on a capacity-two example. - What key and value types are supported? The cache is generic over
KandV. Its hash-map behavior follows the language's key equality rules; in JavaScript,Mapsupports primitive and object keys. - What represents a miss?
get()returnsundefined. If the product must distinguish a missing key from a deliberately storedundefined, return a tagged result or addhas(key); this skeleton keeps the conventional interview signature. - What happens when
put()receives an existing key? Update the same node, move it to the front, and keep the size unchanged. Evicting another key on an update would violate capacity semantics. - Which end is most recent? The list uses
head.nextas most-recently used andtail.prevas least-recently used. Naming both ends before coding prevents reversed eviction logic. - Do we expose removal or iteration? Not in the core API. Both could be added, but iteration would need a declared order and removal would have to update the map and list together.
- Must it be thread-safe? The core model is single-threaded. A production wrapper must remember that
get()mutates recency, so it is not a read-only critical section even though it does not change the value.
Entity extraction
Run the noun pass before reaching for a memorized implementation:
- An LRU cache has a fixed capacity and stores key-value entries.
- A get marks an entry most-recently used.
- A put may evict the least-recently-used entry.
- A HashMap finds an entry's node by key.
- A doubly-linked list orders nodes from head to tail, and every node links to a previous and next node.
Now sort those nouns by responsibility:
| Bucket | Nouns | Model choice |
|---|---|---|
| Facade / context | LRU cache, capacity | LRUCache<K, V> exposes get() and put() and protects the cross-structure invariant |
| Lookup index | HashMap, key | Map<K, Node<K, V>> finds the exact node without scanning recency order |
| Ordering structure | doubly-linked list, head, tail | DoublyLinkedList<K, V> owns constant-time link changes and tail removal |
| Linked entry | node, key, value, previous, next | Node<K, V> holds the cached pair and its two neighboring references |
| Derived labels | most recent, least recent | Positions beside the head and tail sentinels, not separate classes or booleans |
| Excluded concepts | clock, disk, remote server, lock | No TTL, persistence, distribution, or synchronization classes in the core |
The verb pass makes the collaboration explicit:
| Verb phrase | Owner | Reason |
|---|---|---|
| find a key | LRUCache queries its Map | The facade coordinates lookup with the recency mutation that follows |
| mark an entry most recent | DoublyLinkedList.moveToFront() | The list owns neighboring links and can change them in constant time |
| insert a new entry | LRUCache.put() coordinates map and list | Both structures must receive the same new node |
| remove the least-recent entry | DoublyLinkedList.removeLast() | tail.prev identifies it without traversal |
| remove the evicted key | LRUCache deletes it from the Map | The facade owns the invariant that the index and list contain the same entries |
| update an existing value | LRUCache.put() changes the indexed node | Reusing the node avoids duplicate keys and then moves the same object to the front |
The key invariant in plain language is: every real list node appears exactly once in the map under its own key, and every map entry points to exactly that list node. head.next is the most recent real node; tail.prev is the least recent.
Before get("a"), one cache might read MRU → c ⇄ b ⇄ a ← LRU. The map jumps directly to node a, the list detaches it from the tail side, and the order becomes MRU → a ⇄ c ⇄ b ← LRU. No entry between c and a is scanned or shifted.
Core class diagram
Four boxes are enough. The map answers “where is this key?”, while the list answers “which key is oldest?”
class diagram
Core LRU cache model
- 1LRUCache is the facade and owns the capacity plus cross-structure invariant.
- 2The HashMap finds a node by key in average constant time.
- 3The linked list owns recency order between fixed head and tail sentinels.
- 4Each map value is the exact Node that must move after a cache hit.
- 5The list contains those same nodes once, from most recent to least recent.
- 6One self-association represents the prev and next links between Nodes.
The two sentinel nodes never hold cache entries. They make every real node have both a predecessor and successor, so removing the first, middle, or last entry uses the same four link assignments. The sentinels also make an empty list unambiguous: head.next === tail and tail.prev === head.
The Node → Node self-association is drawn once even though each node has both prev and next. Two edges between the same boxes would add no design information and would make the diagram ambiguous.
Critical-flow sequence diagram
Assume capacity is two and recency is MRU → a ⇄ b ← LRU. A new put("c", 3) misses, adds c to the front, briefly makes size three, and evicts b from both structures.
sequence diagram
Put a new key into a full LRU cache
- 1The map proves c is new without scanning the recency list.
- 2The cache creates one node and links it directly after the head sentinel.
- 3The same node enters the map, then size reveals that capacity was exceeded.
- 4The list removes tail.prev, and the map deletes that evicted node's key.
- 5Both structures now contain c then a, so put() returns with the invariant restored.
The temporary size of three exists only inside one synchronous put(). By the time the method returns, b is in neither the list nor the map. Deleting it from only one structure would leave either a ghost list node or a map pointer to an entry that no longer participates in eviction.
An existing-key put() takes a shorter path: map hit, update that node's value, move it to the front, return. Size never grows, so no unrelated key is evicted.
TypeScript skeleton
The skeleton uses generic keys and values, a map to real nodes, and sentinel head and tail nodes. Every list operation changes a constant number of references.
export {};
class Node<K, V> {
public prev: Node<K, V> | null = null;
public next: Node<K, V> | null = null;
private constructor(
public readonly key: K,
public value: V,
) {}
public static entry<K, V>(key: K, value: V): Node<K, V> {
return new Node(key, value);
}
public static sentinel<K, V>(): Node<K, V> {
return new Node<K, V>(undefined as K, undefined as V);
}
}
class DoublyLinkedList<K, V> {
private readonly head = Node.sentinel<K, V>();
private readonly tail = Node.sentinel<K, V>();
public constructor() {
this.head.next = this.tail;
this.tail.prev = this.head;
}
public addFirst(node: Node<K, V>): void {
if (node.prev !== null || node.next !== null) {
throw new Error("cannot add a node that is already linked");
}
const first = this.head.next;
if (first === null) {
throw new Error("head sentinel is disconnected");
}
node.prev = this.head;
node.next = first;
first.prev = node;
this.head.next = node;
}
public moveToFront(node: Node<K, V>): void {
if (node.prev === this.head) return;
this.remove(node);
this.addFirst(node);
}
public removeLast(): Node<K, V> | null {
const last = this.tail.prev;
if (last === null) {
throw new Error("tail sentinel is disconnected");
}
if (last === this.head) return null;
this.remove(last);
return last;
}
private remove(node: Node<K, V>): void {
if (node === this.head || node === this.tail) {
throw new Error("cannot remove a sentinel");
}
const previous = node.prev;
const following = node.next;
if (previous === null || following === null) {
throw new Error("cannot remove an unlinked node");
}
previous.next = following;
following.prev = previous;
node.prev = null;
node.next = null;
}
}
class LRUCache<K, V> {
private readonly nodes = new Map<K, Node<K, V>>();
private readonly order = new DoublyLinkedList<K, V>();
public constructor(private readonly capacity: number) {
if (!Number.isSafeInteger(capacity) || capacity <= 0) {
throw new Error("capacity must be a positive integer");
}
}
public get size(): number {
return this.nodes.size;
}
public get(key: K): V | undefined {
const node = this.nodes.get(key);
if (node === undefined) return undefined;
this.order.moveToFront(node);
return node.value;
}
public put(key: K, value: V): void {
const existing = this.nodes.get(key);
if (existing !== undefined) {
existing.value = value;
this.order.moveToFront(existing);
return;
}
const node = Node.entry(key, value);
this.order.addFirst(node);
this.nodes.set(key, node);
if (this.nodes.size <= this.capacity) return;
const evicted = this.order.removeLast();
if (evicted === null) {
throw new Error("cache exceeded capacity without an LRU node");
}
this.nodes.delete(evicted.key);
}
}
function read(
cache: LRUCache<string, number>,
key: string,
): string {
const value = cache.get(key);
return value === undefined ? "miss" : String(value);
}
const cache = new LRUCache<string, number>(2);
cache.put("a", 1);
cache.put("b", 2);
console.log(`get(a): ${read(cache, "a")}`);
cache.put("c", 3);
console.log(`get(b): ${read(cache, "b")}`);
console.log(`get(a): ${read(cache, "a")}`);
console.log(`get(c): ${read(cache, "c")}`);get(a): 1 get(b): miss get(a): 1 get(c): 3
After inserting a and b, get("a") moves a ahead of b. Inserting c then evicts tail.prev, which is b. The later reads prove that b left the map while a and c remain.
The type assertions exist only inside Node.sentinel(): sentinel payloads are never read, while real nodes always come from Node.entry(). Keeping that exception at one construction point lets the rest of the list remain fully generic.
Design-review checklist
This is where the interviewer asks whether the complexity claim survives every edge case.
- Why HashMap plus doubly-linked list: The map provides average
O(1)key lookup. The list moves a known node and removestail.previnO(1). Neither structure alone provides both operations portably with the required bound. - Why not an array: Finding a key is
O(n)without another index, while moving a hit to the front or removing the first element shifts entries. Adding a map beside the array fixes lookup but not constant-time recency movement. - Why not a single ordered map: A tree-ordered map sorts by key, not access time, and is usually
O(log n). Some languages provide an access-ordered hash map, and JavaScriptMapcan use delete-plus-set insertion-order behavior; those are valid library shortcuts that hide or specialize the same ordering machinery. The explicit map and list is the portable class model. - Why
get()is a mutation: A hit changes which key will be evicted next. Any lock, audit, test, orconstclaim must account for the link update even though the returned value is unchanged. - Sentinel nodes: Fixed head and tail objects remove empty, first, middle, and last-node branches from link manipulation. Their payloads are never entries and must never reach the map.
- One-node identity: Updating an existing key changes the indexed node and moves that same object. Creating a second node would put duplicate keys in the list and make later eviction delete the wrong map entry.
- Eviction ordering: Add the new node, update the map, detect overflow, remove the list tail, then delete that node's key. Whichever exact ordering an implementation chooses, callers must never observe the structures between those coordinated steps.
- Capacity one:
put(a),get(a), thenput(b)must leave only b. Sentinels make removing the sole real node use the same path as every other tail eviction. - Thread safety: One mutex around
get()andput()is the clearest strict-LRU baseline. Striped locks divide keys across a fixed set of locks, but all hits still mutate a global list; sharding the cache by key with one lock and capacity per shard often gives a more honest scalable trade-off at the cost of approximate global LRU. - Deadlock risk: If an implementation combines key stripes with a recency lock, define one acquisition order. A hit that takes them in the reverse order of eviction can deadlock even when each method looks locally safe.
- TTL variant: Expiry adds a clock and another ordering question. Checking TTL only on
get()is lazy cleanup; proactive cleanup needs a timer wheel or expiry heap, and neither should silently replace the LRU order. - LFU variant: Least-frequently used needs counts and frequency buckets, not a renamed tail pointer. Policy changes can require a different model rather than another flag on
Node. - Testing recency: Cover hit-to-front, miss-without-mutation, eviction after a hit, repeated hits, and insertion order. Assert returned values and the exact next key evicted.
- Testing updates: Update an existing key at full capacity and assert its value changes, its recency moves, size stays fixed, and no third key disappears.
- Testing boundaries: Cover capacity one, invalid capacities, object keys, and a stored value that is otherwise falsy. Property tests can compare random operations with a slow reference model.
- With more time: Add deletion, resizing, eviction callbacks, metrics, synchronization, TTL, or sharding only after stating how each feature changes the core invariant and complexity promise.
Checkpoint
Answer all three to mark this lesson complete
The LRU cache earns constant-time behavior by keeping lookup and recency separate, then restoring their shared invariant before each call returns. Next, a rate limiter and then chess close the case studies.