LLD Cohort · Week 6 · Sat 25 Apr 2026 · recording 1h41m (+ Sun 26 Apr re-recording intro)

LRU Cache Implementation & Rate Limiter

The LRU cache is built from scratch with a doubly linked list, dummy head/tail nodes and a hash map, including the live debugging. Then comes an introduction to rate limiting: why it is needed, DDoS, the token bucket algorithm in detail, the high-level picture, and how Strategy and Factory fit the LLD.

Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember

LRU Cache: concept & data-structure choice ▶ 0:00:02 – 0:19:40

LRU = "Least Recently Used". When the cache is full, evict the item that has gone untouched the longest. Keep the order in a doubly linked list and find nodes through a hash map, so both get and put are O(1).

The problem

A cache is fast in-memory storage with a limited capacity. While there is space, just insert. When a new item arrives and the cache is full, the replacement strategy decides who leaves. LRU removes the least recently accessed item. Here "access" means a get or a put of that key.

Real-life picture: timestamps

The teacher stamped each item with a clock time. A came at 5 PM, B at 6 PM, C at 7 PM. Then C is used again at 8 PM, and B is updated at 9 PM. Now A (5 PM) is the oldest. When D arrives at 10 PM, A is evicted and D becomes the newest. The order keeps changing with every access, and that is what makes LRU tricky.

Building it step by step

  1. Only a hash map? Lookup is O(1), but the map has no idea which key is the oldest. Finding it means scanning everything, which is O(n).
  2. Hash map + an ordered list? Keep keys in order from LRU at one end to MRU at the other. On access, move the key to the MRU end. On overflow, remove from the LRU end.
  3. Which list? An array needs O(n) to remove from the middle. Ganesh asked this in class. A singly linked list cannot unlink a node unless you know the node before it, so you would scan again. A doubly linked list has a prev pointer, so unlinking any node is O(1).
  4. How do we reach a node in O(1)? The hash map stores key → node, so it holds a reference (the node's address) into the linked list. Harshit asked this in class. "Where is my Aadhaar card? I go straight to its drawer, I don't search every document."
PieceJob
Doubly linked listIs the cache. Keeps the order: head = most recently used, tail = least recently used.
Hash map key → nodeOnly a fast lookup table, so we never walk the list.

Walk-through (capacity 3, head = MRU)

AccessList (MRU … LRU)What happened
A, B, CC, B, ASpace available, each new key goes to the head
CC, B, AAlready at the head
BB, C, AMove B to the head
AA, B, CMove A to the head
DD, A, BFull: evict tail C, add D at the head
EE, D, AEvict B
FF, E, DEvict A

Every step uses three O(1) operations: remove node, add to head, and remove last. "Move to head" is just remove + add to head.

Doubt: Doesn't the hash map lookup itself take time?
→

No. Checking whether a key exists is O(1) on average (O(α), as shown in the HashMap lecture). Once you have the node reference, you jump straight to it in the list.

Doubt: Can we use an array instead of a doubly linked list?
→

You can, but removing from the middle of an array shifts elements, which is O(n). The whole point is O(1) for both get and put.

Doubt: Why doubly and not singly linked?
→

To unlink node X you must change X.prev.next. A singly linked list has no prev, so you would walk from the head to find it, which is O(n) and defeats the purpose.

Key takeaways
  • LRU cache = doubly linked list (the order) + hash map from key to node (the lookup).
  • Head = most recently used, tail = least recently used. Pick a convention and stick to it.
  • Every access moves the node to the head. Eviction removes the node just before the tail.
  • It is a famous question because it tests data structures (DLL + map) and a little LLD at the same time.

LRU Cache: implementation ▶ 0:19:40 – 1:21:24

Three small classes: Node, DoublyLinkedList (add_to_head, remove_node, move_to_head) and LRUCache (get, put), with dummy head and tail nodes.

Design decisions the teacher made

Pointer surgery, visualised

add_to_head(node), going from head ⇄ first to head ⇄ node ⇄ first. There are 4 links to set:

  1. node.next = head.next (secure the old first node before you lose it)
  2. node.prev = head
  3. head.next.prev = node (old first points back to node)
  4. head.next = node

remove_node(node), going from A ⇄ node ⇄ B to A ⇄ B. There are 2 links to set:

  1. node.prev.next = node.next
  2. node.next.prev = node.prev

Python code (no OrderedDict)

Python
from typing import Optional


class Node:
    def __init__(self, key: int = 0, value: int = 0):
        self.key = key            # key is stored so eviction can delete it from the map
        self.value = value
        self.prev: Optional["Node"] = None
        self.next: Optional["Node"] = None


class DoublyLinkedList:
    """head side = most recently used (MRU), tail side = least recently used (LRU)."""
    def __init__(self):
        self.head = Node()        # dummy (sentinel) nodes: no None checks anywhere
        self.tail = Node()
        self.head.next = self.tail
        self.tail.prev = self.head          # the line the teacher first forgot

    def add_to_head(self, node: Node) -> None:
        # 4 pointer changes: node <-> old first node, and head <-> node
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def remove_node(self, node: Node) -> None:
        node.prev.next = node.next
        node.next.prev = node.prev

    def move_to_head(self, node: Node) -> None:
        self.remove_node(node)              # move = remove + add to head
        self.add_to_head(node)

    def remove_last(self) -> Node:
        last = self.tail.prev               # real last node sits just before dummy tail
        self.remove_node(last)
        return last

    def print(self) -> None:
        cur, out = self.head.next, []
        while cur is not self.tail:         # tail (not None) is our stop marker
            out.append(f"{cur.key}:{cur.value}")
            cur = cur.next
        print(" -> ".join(out) or "(empty)")


class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = DoublyLinkedList()         # keeps the ORDER
        self.lookup: dict[int, Node] = {}       # key -> node, for O(1) jumps

    def get(self, key: int) -> int:
        if key not in self.lookup:
            return -1
        node = self.lookup[key]
        self.cache.move_to_head(node)           # reading also counts as "using"
        return node.value

    def put(self, key: int, value: int) -> None:
        if key in self.lookup:                  # existing key: update + make it MRU
            node = self.lookup[key]
            node.value = value
            self.cache.move_to_head(node)
            return
        node = Node(key, value)                 # new key: add first ...
        self.lookup[key] = node
        self.cache.add_to_head(node)
        if len(self.lookup) > self.capacity:    # ... then evict if we went over
            lru = self.cache.remove_last()
            del self.lookup[lru.key]            # remove from BOTH places


# usage
c = LRUCache(3)
c.put(1, 10); c.put(2, 20); c.put(3, 100)
c.cache.print()          # 3:100 -> 2:20 -> 1:10
c.put(4, 200)            # full -> evicts key 1 (least recently used)
c.cache.print()          # 4:200 -> 3:100 -> 2:20
c.put(2, 40)             # update -> 2 jumps to the front
print(c.get(3))          # 100  (3 moves to the front)
c.cache.print()          # 3:100 -> 2:40 -> 4:200
print(c.get(1))          # -1
classDiagram
  class LRUCache {
    +capacity
    +cache
    +lookup
    +get(key)
    +put(key, value)
  }
  class DoublyLinkedList {
    +head
    +tail
    +add_to_head(node)
    +remove_node(node)
    +move_to_head(node)
    +remove_last()
  }
  class Node {
    +key
    +value
    +prev
    +next
  }
  LRUCache *-- DoublyLinkedList
  DoublyLinkedList *-- Node
  LRUCache --> Node

LRUCache has a DoublyLinkedList, and the list dies with the cache, so this is composition. Ajay answered this in class.

Bugs hit during live coding (and the fixes)

SymptomCauseFix
NullPointerException: lookupTable is nullThe hash map was declared but never createdInitialise lookup = {} in the constructor
Printing showed dummy 0:0 nodesThe print loop used null as its stop markerStart at head.next and stop at tail, not at None
Eviction crashed: prev is nullConstructor set head.next = tail but forgot tail.prev = head. add_to_head also only set the next links.Always set both directions (the 4-pointer recipe above)
Cycle in the listA typo: node.next = head instead of node.prev = headSame recipe
Evicted key still "found"The node was removed from the list but not from the mapdel lookup[lru.key]. This is why each Node stores its key.
Small correction:

At the end of class one get call printed nothing and the teacher left it unresolved. His own reference version was identical in logic. The code above, with both-direction links and removal from both the list and the map, passes the class scenario: put (1,10), (2,20), (3,100), (4,200), update (2,40), then get(3) and get(4).

Interview mindset

"They are not expecting fully working code. They are checking your tenacity while fixing it." Debug out loud. Add a print() of the list after each operation, as the teacher did, and test get as well as put. Ajay asked to "invoke get once", which is exactly what an interviewer would do.

The Python shortcut: OrderedDict

Python's collections.OrderedDict is a hash map plus a doubly linked list internally, so LRU becomes a few lines. Mention it in the interview, but expect to be asked to write the manual version.

Python
from collections import OrderedDict


class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.data: OrderedDict[int, int] = OrderedDict()   # remembers order

    def get(self, key: int) -> int:
        if key not in self.data:
            return -1
        self.data.move_to_end(key)          # end = most recently used
        return self.data[key]

    def put(self, key: int, value: int) -> None:
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)   # front = least recently used


# usage
c = LRUCache(2)
c.put(1, 1); c.put(2, 2); c.get(1); c.put(3, 3)
print(list(c.data))      # [1, 3]  (2 was evicted)
Java vs Python

Java's equivalent shortcut is LinkedHashMap with accessOrder = true, overriding removeEldestEntry(). The teacher's "generic" reference version used LRUCache<K, V>, and he advised against generics in an interview because they add typing time. In Python, type hints such as dict[int, Node] are optional, so the problem does not arise.

Remember

Complexity: get O(1), put O(1), space O(capacity). For thread safety (an NFR), wrap get and put in one threading.Lock, because even get changes the list.

Key takeaways
  • Dummy head and tail remove all null and edge-case checks. The tail, not None, is the end marker.
  • Linking is always two-way: add_to_head sets 4 pointers, remove_node sets 2.
  • On eviction, remove from both the list and the map, so store the key inside the node.
  • put on an existing key = update the value + move to head. put on a new key = add to head, then evict the tail if over capacity.
  • Visualise every pointer change on paper. Don't memorise it.

Rate Limiter: what and why ▶ 1:21:24 – 1:30:00 · re-recorded intro 26 Apr 0:00:05 – 0:01:30

A rate limiter controls how many requests a user (or IP, or API key) can make in a given time window, and rejects the rest.

The problem

Every API call costs you compute and database work. Normal users call your "fetch weather" API now and then. An abuser runs while True: call_api() for five days. The outside world is "cruel". Without protection, abusers (or bots) consume your resources and real users are starved. This also covers a DDoS (Distributed Denial of Service) attack: many machines everywhere send something like 1,000 requests per second, so a real user cannot even open a site such as the CBSE results page.

Real-life picture: the langar counter

At a free-food counter, a truly hungry person asks once. A crowd of fake people keep asking a hundred times each and throw the food away. The counter is "busy", but the hungry person never gets served. A rate limiter is the volunteer who says: "one plate per person per round".

Seen in real life: OTP "resend after 1 minute" and "too many attempts, blocked for 30 minutes". Instagram's "too many requests" when posting or commenting too fast. Shubham's company saw a hacker hitting the OTP API back-to-back to brute-force codes. Stripe, AWS, GitHub, Twitter, NGINX and Redis all use rate limiting.

Core goals: protect the system from abuse, overload and DDoS, and share capacity fairly between users.

Where it sits (high-level picture)

  1. Clients → API gateway or proxy. Every request passes through here. Gateways often provide generic rate limiting out of the box.
  2. A custom rate-limiter service checks rules stored in a DB or config: per API, per customer tier (premium users get higher limits), stricter rules for sensitive endpoints.
  3. Allowed requests go on to the microservices. Rejected ones get HTTP 429 Too Many Requests.

Companies build their own limiter instead of using only the gateway's because they want customised rules, such as a premium-customer experience, not just DDoS protection. Rate limiting is also a full HLD topic of its own.

Key takeaways
  • A rate limiter protects resources and keeps them fair for real users.
  • It sits in front of services (gateway or proxy) and reads its rules from config.
  • Limits are tracked per key (user, IP or API key). Over the limit means reject with 429.

Token Bucket algorithm ▶ 1:30:00 – 1:37:43

Each user has a bucket of tokens. A request spends one token. Tokens drip back in at a fixed rate, up to a maximum capacity.

Real-life picture

A prepaid metro card that tops itself up by 5 rides per minute but can never hold more than 10 rides. Ride while you have balance. At zero, you wait for the top-up.

State per bucket

Algorithm (lazy refill)

  1. A request arrives for a user. Look up (or create) their bucket.
  2. Refill first: earned = (now - last_refill) × rate, then tokens = min(capacity, tokens + earned), then last_refill = now. Nothing runs in the background. The refill is calculated only when the user shows up.
  3. If tokens ≥ 1: spend one and allow. Otherwise reject (429).

Teacher's numbers: capacity 10, rate 5 per minute. The user drained the bucket to 0 at 6:00. They return at 6:15 and have earned 15 × 5 = 75 tokens, but get min(10, 0 + 75) = 10. If they return at 6:01 instead, they get only 5. The min with capacity is what stops someone from "saving up" a huge burst.

See TokenBucketStrategy in the code in the next section.

Doubt: Is this like Claude Code / ChatGPT telling you "you've hit your limit"?
→

Yes. Product usage limits are rate limiting. There are many variations, and the topic is large enough to be a full HLD question.

Key takeaways
  • One bucket per user, so an abuser only drains their own bucket.
  • Refill lazily on each request, using the time since the last refill, capped at capacity.
  • It allows short bursts (up to capacity) while enforcing an average rate. Stripe and AWS use it.

Other algorithms & the LLD (Strategy + Factory) ▶ 1:39:32 – 1:41:07

The rate-limiter service holds one interchangeable RateLimitingStrategy (Strategy pattern). A factory builds the chosen algorithm from config.

The teacher named the algorithms to read up on (token bucket, fixed window, sliding window) and promised the sliding window with a double-ended queue for the next session. That session was the lost 26 Apr recording, so the rows marked "not in recording" below are standard reference material.

AlgorithmHow it worksStrengthWeakness
Token bucket (taught)Tokens refill at a fixed rate up to capacity. Each request spends one.Allows controlled bursts. O(1) memory per user.Two values to tune (capacity and rate)
Fixed window counter (named)A counter per user per fixed time slot (e.g. per calendar minute). It resets when the slot changes.Simplest. O(1) memory.Boundary burst: 100 requests at 10:00:59 plus 100 at 10:01:00 means 200 in 2 seconds
Sliding window log (named, deque)Keep each request's timestamp in a deque. Pop from the front any older than now - window. Allow if len < limit.Exact, with no boundary burstMemory is O(limit) per user
Sliding window counter (not in recording)Weighted mix of the current and previous fixed-window countsClose to exact, O(1) memoryAn approximation
Leaky bucket (not in recording)Requests enter a queue that drains at a constant rate. If the queue is full, drop.Smooth, constant output rateBursts are queued or dropped, which adds latency

LLD: how the patterns fit

Python
import threading
import time
from abc import ABC, abstractmethod
from collections import deque


class RateLimitingStrategy(ABC):
    @abstractmethod
    def allow_request(self, user_id: str) -> bool: ...


class TokenBucketStrategy(RateLimitingStrategy):
    """Each user has a bucket of `capacity` tokens, refilled at `refill_rate` tokens/sec."""

    class Bucket:
        def __init__(self, capacity: int):
            self.tokens = float(capacity)          # start full
            self.last_refill = time.monotonic()

    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity
        self.refill_rate = refill_rate
        self.buckets: dict[str, TokenBucketStrategy.Bucket] = {}
        self.lock = threading.Lock()

    def _refill(self, b: "TokenBucketStrategy.Bucket") -> None:
        # Lazy refill: computed only when the user shows up
        now = time.monotonic()
        earned = (now - b.last_refill) * self.refill_rate
        b.tokens = min(self.capacity, b.tokens + earned)   # never above capacity
        b.last_refill = now

    def allow_request(self, user_id: str) -> bool:
        with self.lock:
            b = self.buckets.setdefault(user_id, self.Bucket(self.capacity))
            self._refill(b)
            if b.tokens >= 1:
                b.tokens -= 1
                return True
            return False


class FixedWindowStrategy(RateLimitingStrategy):
    """At most `limit` requests per user in each fixed window (e.g. 10:00:00-10:00:59)."""

    def __init__(self, limit: int, window_sec: float):
        self.limit = limit
        self.window = window_sec
        self.counters: dict[str, tuple[int, int]] = {}   # user -> (window_id, count)
        self.lock = threading.Lock()

    def allow_request(self, user_id: str) -> bool:
        with self.lock:
            window_id = int(time.monotonic() // self.window)
            wid, count = self.counters.get(user_id, (window_id, 0))
            if wid != window_id:              # a new window started -> reset
                count = 0
            if count < self.limit:
                self.counters[user_id] = (window_id, count + 1)
                return True
            self.counters[user_id] = (window_id, count)
            return False


class SlidingWindowLogStrategy(RateLimitingStrategy):
    """Keep recent request times in a deque; allow if fewer than `limit` in the last window."""

    def __init__(self, limit: int, window_sec: float):
        self.limit = limit
        self.window = window_sec
        self.logs: dict[str, deque[float]] = {}
        self.lock = threading.Lock()

    def allow_request(self, user_id: str) -> bool:
        with self.lock:
            now = time.monotonic()
            log = self.logs.setdefault(user_id, deque())
            while log and log[0] <= now - self.window:   # drop times that slid out
                log.popleft()
            if len(log) < self.limit:
                log.append(now)
                return True
            return False


class RateLimiterFactory:
    @staticmethod
    def create(kind: str, **cfg) -> RateLimitingStrategy:
        if kind == "token_bucket":
            return TokenBucketStrategy(cfg["capacity"], cfg["refill_rate"])
        if kind == "fixed_window":
            return FixedWindowStrategy(cfg["limit"], cfg["window_sec"])
        if kind == "sliding_log":
            return SlidingWindowLogStrategy(cfg["limit"], cfg["window_sec"])
        raise ValueError(f"unknown strategy: {kind}")


class RateLimiter:
    """The service in front of the API. It does not care WHICH algorithm is plugged in."""

    def __init__(self, strategy: RateLimitingStrategy):
        self.strategy = strategy

    def set_strategy(self, strategy: RateLimitingStrategy) -> None:
        self.strategy = strategy             # swap the algorithm at runtime

    def handle(self, user_id: str) -> str:
        return "200 OK" if self.strategy.allow_request(user_id) else "429 Too Many Requests"


# usage
limiter = RateLimiter(RateLimiterFactory.create("token_bucket", capacity=3, refill_rate=1))
print([limiter.handle("abuser") for _ in range(5)])
# ['200 OK', '200 OK', '200 OK', '429 Too Many Requests', '429 Too Many Requests']
print(limiter.handle("real_user"))      # 200 OK  (own bucket, the abuser cannot starve it)
time.sleep(1.1)
print(limiter.handle("abuser"))         # 200 OK  (about 1 token refilled after ~1 s)

limiter.set_strategy(RateLimiterFactory.create("sliding_log", limit=2, window_sec=1))
print([limiter.handle("u1") for _ in range(3)])
# ['200 OK', '200 OK', '429 Too Many Requests']
classDiagram
  class RateLimitingStrategy {
    +allow_request(user_id) bool
  }
  class TokenBucketStrategy {
    +capacity
    +refill_rate
    +buckets
    +allow_request(user_id) bool
  }
  class FixedWindowStrategy {
    +limit
    +window
    +counters
    +allow_request(user_id) bool
  }
  class SlidingWindowLogStrategy {
    +limit
    +window
    +logs
    +allow_request(user_id) bool
  }
  class RateLimiter {
    +strategy
    +set_strategy(strategy)
    +handle(user_id) str
  }
  class RateLimiterFactory {
    +create(kind) RateLimitingStrategy
  }
  TokenBucketStrategy ..|> RateLimitingStrategy
  FixedWindowStrategy ..|> RateLimitingStrategy
  SlidingWindowLogStrategy ..|> RateLimitingStrategy
  RateLimiter o-- RateLimitingStrategy
  RateLimiterFactory --> RateLimitingStrategy
Java vs Python

Java would use an interface RateLimitingStrategy, ConcurrentHashMap and synchronized (or ReentrantLock). Python uses abc.ABC + @abstractmethod and a threading.Lock around the read-modify-write. Use time.monotonic(), not wall-clock time, so clock changes cannot break the refill maths.

Common mistake:

Storing one global counter instead of one per user. Then a single abuser blocks everyone, which is exactly the langar problem again. Key the state by user, IP or API key.

Key takeaways
  • The LLD is simple. The algorithms are the interview substance.
  • Strategy: one interface, many algorithms, swappable. Factory: build the one named in config.
  • Fixed window is simplest but allows bursts at the boundary. The sliding log (deque) is exact. Token bucket allows controlled bursts.
  • Keep state per user, and lock it for concurrent requests.