CMD Guide
HomeSystem DesignSystem Design Trade-offs

Token Bucket vs Leaky Bucket

Both algorithms cap a stream to an average rate by pairing one counter with the clock, and they differ in a single design choice: a token bucket lets a caller save up permits and spend them all at once, so a burst passes instantly until the saved permits run out; a leaky bucket forces every admitted request through a queue that drains at a fixed rate, erasing the burst and emitting a perfectly steady trickle.

That one difference is the whole trade-off. Below we run the same input through both with real numbers, look at correct pseudocode (the version you would actually ship stores two numbers, not a background timer), and place both against the rate-limiters you will really be choosing between in an interview or a design doc: fixed-window and sliding-window counters.

The two mechanisms, precisely

Token bucket. A bucket holds up to B tokens (its capacity = the maximum burst). Tokens are added at a steady r tokens/second, capped at B. Each request must remove cost tokens (usually 1) to be admitted; if the bucket has enough, the request passes and tokens are debited, otherwise it is rejected (an HTTP 429) or made to wait. Idle time accumulates tokens up to B — that stored credit is what permits a burst. Sustained throughput can never exceed r because that is the only inflow.

Leaky bucket. Model a fixed-size FIFO queue of capacity B that is drained at a constant r requests/second (the "leak"). Arrivals join the queue; if the queue is full they are dropped (overflow). Because the drain rate is constant regardless of how ragged the input is, the output is smooth — the burst is buffered, not passed on. The cost is queueing latency: an admitted request may wait behind everything already in the bucket.

diagram
diagram

Worked trace: capacity 10, refill 2/s, a burst of 15

Bucket starts full (10 tokens). At t = 0s a burst of 15 requests arrives at once, then 4 arrive at t = 1s, 1 at t = 2s, then idle. Each request costs 1 token.

TimeArrivalsTokens available (after refill)PassedDroppedTokens left
0s1510 (start full)1050
1s40 + 2 = 2220
2s10 + 2 = 2101
3s01 + 2 = 3003
4s03 + 2 = 5005

Read the first row carefully: 10 of the 15 pass in the same instant — that is the burst, funded by the 10 stored tokens — and the other 5 are rejected on the spot. After that the bucket is empty and admission tracks the refill: about 2 per second. Idle seconds (t=3,4) rebuild credit toward the cap of 10, ready for the next burst.

The same input through a leaky bucket

Now a queue of capacity 10, drained at 2/s, starting empty. Before each arrival we first subtract whatever leaked since the last event.

TimeArrivalsQueue depth (after leak)AdmittedDroppedQueue after
0s15010510
1s410 − 2 = 82210
2s110 − 2 = 8109
3s09 − 2 = 7007

The admission counts land in the same place as the token bucket — 10 in, 5 dropped on the burst, then ~2/s — because both bound the average to r with the same headroom B. The difference is what the downstream sees. The token bucket released all 10 at t = 0s; the leaky bucket releases them one every 0.5s, so the 10th admitted request does not exit until t ≈ 5s. The burst is gone, traded for up to ~5 seconds of queue latency. That is the entire choice in one sentence: token bucket preserves burstiness, leaky bucket destroys it and charges latency for the privilege.

Pseudocode you would actually ship

Neither production implementation runs a timer that ticks tokens in or drains the queue. You store two numbers — a level and a last-updated timestamp — and reconstruct the current level lazily from elapsed time on each request. This is what makes it cheap enough for millions of keys in Redis.

class TokenBucket:            # allow bursts up to `capacity`
    def __init__(self, capacity, refill_rate):
        self.capacity   = capacity        # max burst (B)
        self.rate       = refill_rate     # tokens per second (r)
        self.tokens     = capacity        # start full
        self.last       = monotonic()     # NOT wall-clock

    def allow(self, cost=1):
        now = monotonic()
        # lazily credit tokens for elapsed time, capped at capacity
        self.tokens = min(self.capacity,
                          self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens >= cost:
            self.tokens -= cost
            return True                   # admit
        return False                      # reject (429)

class LeakyBucket:            # delay-based queue-less leaky bucket (GCRA)
    def __init__(self, capacity, leak_rate):
        self.capacity      = capacity     # queue capacity in REQUESTS (B); max tolerated delay = B / rate seconds
        self.rate          = leak_rate    # leak rate per second (r)
        self.next_dispatch = monotonic()  # time when the next request is allowed to execute

    def allow(self):
        now = monotonic()
        # The earliest this request is scheduled to execute
        scheduled_time = max(now, self.next_dispatch)
        delay = scheduled_time - now
        
        # Convert capacity (requests) into the max tolerated delay (seconds), then drop
        # any request that would have to wait that long or longer
        max_delay = self.capacity / self.rate
        if delay >= max_delay:
            return False, 0               # drop/reject (429)
            
        # Update the next dispatch time to schedule the next permit
        self.next_dispatch = scheduled_time + (1.0 / self.rate)
        return True, delay                # caller must sleep(delay) before executing request

The >= matters. A request whose delay equals capacity / rate already has capacity requests' worth of drain scheduled ahead of it — the equivalent queue is full — so it must be rejected. With a plain > the limiter admits capacity + 1 requests on a cold burst: re-run the burst-of-15 trace against the code and you get 11 admitted instead of the 10 the table shows.

Why the naive version is wrong. The tempting implementation spawns a background thread per bucket that adds a token (or drains one) every 1/r seconds. It breaks three ways: (1) it does not scale — one goroutine/thread per API key means millions of timers; (2) timer granularity and drift make the real rate wrong under load; (3) it is stateful in RAM, so it cannot be shared across servers. The lazy refill/drain form above is stateless enough to live in a single Redis key and be updated atomically. Also note that if a leaky bucket returns True immediately without returning a delay or sleeping, it is just a meter, not a shaper — a burst will pass immediately downstream. The GCRA/virtual-scheduling approach above avoids background timers by computing the exact dispatch delay per request. Also note monotonic(), not wall-clock: an NTP step backwards makes (now - last) negative and corrupts the rate limits.

When to use which — and versus the alternatives

The real decision is rarely token-vs-leaky in isolation; it is "which of the four common rate-limiters fits this endpoint." The other two you must know are fixed-window and sliding-window counters.

Crisp rule: choose token bucket when bursts are legitimate and you want to allow them under an average cap; choose leaky bucket when a downstream needs a constant, burst-free feed and you can pay latency; choose fixed-window for the cheapest coarse quota; choose sliding-window counter when you need accurate, burst-suppressing limits without a full log.

Pitfalls

Takeaways


Sources: Tanenbaum & Wetherall, Computer Networks (leaky- and token-bucket traffic shaping); the ATM Forum's GCRA / virtual-scheduling formulation of the leaky bucket; Stripe Engineering, "Scaling your API with rate limiters"; Cloudflare's sliding-window rate-limiter analysis; and the NGINX limit_req (leaky-bucket) documentation. Re-authored and deepened for this guide with an explicit numeric trace, shippable lazy-refill pseudocode, and a comparison against fixed-window and sliding-window limiters.

🤖 Don't fully get this? Learn it with Claude

Stuck on Token Bucket vs Leaky Bucket? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Token Bucket vs Leaky Bucket** (System Design) and want to truly understand it. Explain Token Bucket vs Leaky Bucket from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Token Bucket vs Leaky Bucket** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Token Bucket vs Leaky Bucket** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Token Bucket vs Leaky Bucket** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes