Engineering Atlas › Low-Level Design

Code an LRU cache

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

Entities and relationships

The cache composes one index and one list, and they always hold the same set of nodes. That invariant is what every operation must preserve: if the map and list disagree, the cache leaks memory or returns a node that is no longer linked.

Class diagram

classDiagram
    class LRUCache {
        -int capacity
        -Map index
        -Node head
        -Node tail
        -Clock clock
        +get(key) value
        +put(key, value, ttl)
        +delete(key) bool
        +size() int
        +keys() List
        -unlink(node)
        -pushFront(node)
    }
    class Node {
        +key
        +value
        +int expiresAt
        +Node prev
        +Node next
    }
    class Clock {
        <<interface>>
        +now() int
    }
    LRUCache "1" *-- "many" Node
    LRUCache ..> Clock
    Node --> Node : prev, next

Key flows

sequenceDiagram
    participant C as Caller
    participant L as LRUCache
    participant M as Index map
    participant D as Recency list
    C->>L: put("c", 3) with cache full
    L->>M: lookup "c"
    M-->>L: miss
    L->>D: lru = tail.prev
    L->>D: unlink(lru)
    L->>M: delete lru.key
    L->>D: pushFront(new node c)
    L->>M: index["c"] = node
    C->>L: get("a")
    L->>M: lookup "a"
    M-->>L: node
    L->>L: expired? compare clock.now() with expiresAt
    L->>D: unlink(node), pushFront(node)
    L-->>C: value of a

get on a hit does three things: look up, check expiry, move to front. If the entry has expired, get unlinks it, deletes it from the map and reports a miss. put on an existing key updates the value and expiry in place and moves the node to the front. put on a new key evicts first if full, then links the new node at the front. Eviction before insertion matters: inserting first briefly holds capacity + 1 entries, which breaks the invariant a test can observe.

Design patterns used and why

Concurrency and edge cases

Down the stack

Layer 1The contract

  • After any operation, size() <= capacity, and the map and list contain the same keys.
  • get never returns an entry for which now >= expiresAt.
  • If k was accessed (by get or put) more recently than j, j is evicted before k.
  • get, put and delete take O(1) average time, independent of capacity.

Layer 2The mechanism

unlink(n) sets n.prev.next = n.next and n.next.prev = n.prev. pushFront(n) sets n.next = head.next, n.prev = head, head.next.prev = n, head.next = n. A hit is one hash lookup plus these eight pointer writes.

Real implementations follow the same shape or deliberately depart from it. CPython's C functools.lru_cache keeps a dict plus a circular doubly linked list with one root link acting as both sentinels. Go's groupcache/lru composes a map with container/list. Redis does not keep a list at all: each object header has a 24-bit LRU clock field, and on eviction Redis samples maxmemory-samples keys (default 5) into a 16-entry candidate pool and evicts the oldest. Memcached splits its LRU into hot, warm and cold segments and bumps an item at most once per interval. The Linux kernel keeps active and inactive page lists, and newer kernels use multi-generational LRU.

Layer 3The machine

A node with a string key, a value, an expiry and two pointers is about 56 to 64 bytes in Go, plus the map's bucket entry, so a million entries costs roughly 100 MB before counting the values. Nodes are scattered across the heap, so walking or relinking them is pointer chasing: each node not in CPU cache costs a main-memory access of about 100 ns. Worse, every hit writes to the neighbours' cache lines. On a multi-core server, when two cores touch the same nodes or the same mutex, the 64-byte lines bounce between cores at tens of nanoseconds per transfer. A mutex acquire that is uncontended costs about 20 ns; under heavy contention, threads park in the kernel (futex on Linux) and the cost jumps to microseconds. For a garbage-collected runtime, a million pointer-rich nodes also add to every GC mark phase.

Why it's built this way

  1. Lookups must be O(1), so the index is a hash map.
  2. Eviction needs the least recently used item in O(1), and hits must reorder in O(1), so recency is a doubly linked list keyed by node pointers.
  3. Moving nodes on every read turns reads into writes, so a concurrent cache needs a lock on reads.
  4. Locks and cache-line bouncing limit throughput on many cores, so large caches shard, sample (Redis), segment (Memcached) or buffer reads and replay them in batches (Caffeine).
  5. Exact LRU is just an approximation of "keep what will be used next", so production caches accept approximate recency when it buys throughput.

Extensibility

Leadership lens

Check yourself

  1. Why does the node store its key when the map already maps key to node?

    Show answer

    Eviction starts from the list tail, not from a key. To delete the evicted node from the map in O(1), you need its key on the node.

  2. Why does a read-write lock not speed up a concurrent LRU?

    Show answer

    A get hit moves the node to the front, which mutates shared pointers. Every hit therefore needs the exclusive lock, so readers never run in parallel.

  3. A TTL of 100 ms is set at time 0. Is the entry returned at time 100 in this design?

    Show answer

    No. Entries are expired when now >= expiresAt, so the entry lives during [0, 100) and is gone at 100.

  4. What workload makes LRU perform badly, and what fixes it?

    Show answer

    A one-off scan over many cold keys pushes out the hot working set. Segmented LRU or frequency-aware admission such as W-TinyLFU protects frequently used keys.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →