DEV Community

Timevolt
Timevolt

Posted on

Rate Limiting Like a Time Lord: Building a Token Bucket from Scratch

The Quest Begins (The "Why")

I still remember the night our API started returning 429s like confetti at a parade. Users were complaining, the monitoring dashboard lit up like a Christmas tree, and I felt like I was trying to hold back a flood with a sieve. We had a simple fixed‑window counter in place — reset the count every minute and reject if you went over the limit. It worked… until it didn’t. Bursts of traffic would slam the limiter, then the next minute we’d be wide open again, letting a sudden surge through. It was like trying to catch a wave with a bucket that only opens once every sixty seconds.

Honestly, I was frustrated. I knew there had to be a better way to smooth out those spikes while still protecting our backend. That’s when I dove into the classic algorithms literature and rediscovered the token bucket. The idea felt like discovering a secret level in a game — simple, elegant, and surprisingly powerful.

The Revelation (The Insight)

The token bucket solves the exact problem we faced: it allows short bursts up to a configurable limit while enforcing a long‑term average rate. Imagine a bucket that leaks water at a steady rate (the refill rate) but can hold a maximum amount (the burst capacity). Each request consumes a token; if the bucket is empty, the request waits or is rejected. If traffic quiets down, the bucket slowly refills, ready for the next burst.

Here’s the ASCII picture that helped me visualize it:

   +-------------------+
   |   Token Bucket    |
   |  (capacity = B)   |
   +--------+----------+
            |
            v   (consume 1 token per request)
   +-------------------+
   |   Incoming Request|
   +-------------------+
            |
            v   (if token available)
   +-------------------+
   |   Forward to API  |
   +-------------------+
            ^
            |
   (refill rate R tokens/sec)
Enter fullscreen mode Exit fullscreen mode

The magic is in the refill: we don’t reset the counter at hard boundaries; we continuously add tokens based on elapsed time. This means:

  • Burst handling – up to B requests can go through instantly, even if the average rate is low.
  • Smooth throttling – after a burst, the limiter gradually allows more traffic as tokens replenish.
  • Simplicity – only two state variables are needed: the current token count and the last refill timestamp.

Compare that to the fixed window counter, which either over‑allows (if the burst hits right after a reset) or under‑allows (if the burst straddles a window edge). The token bucket eliminates that jitter, giving us a much fairer experience for users while still protecting our servers.

Wielding the Power (Code & Examples)

Let’s look at the naive fixed‑window approach first — our “struggle” code:

import time
from threading import Lock

class FixedWindowLimiter:
    def __init__(self, max_per_window, window_sec):
        self.max = max_per_window
        self.window = window_sec
        self.count = 0
        self.window_start = time.time()
        self.lock = Lock()

    def allow(self):
        now = time.time()
        with self.lock:
            if now - self.window_start >= self.window:
                # reset window
                self.window_start = now
                self.count = 0
            if self.count < self.max:
                self.count += 1
                return True
            return False
Enter fullscreen mode Exit fullscreen mode

The problem? If a burst arrives just after window_start is set, we can blow past the limit before the next reset. Conversely, a burst that straddles the edge gets half‑allowed, half‑denied — annoying for clients.

Now the victorious token bucket implementation:

import time
from threading import Lock

class TokenBucketLimiter:
    def __init__(self, rate, capacity):
        """
        rate    -> tokens added per second (refill rate)
        capacity-> max tokens the bucket can hold (burst size)
        """
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity          # start full
        self.timestamp = time.time()
        self.lock = Lock()

    def _refill(self, now):
        """Add tokens based on elapsed time."""
        elapsed = now - self.timestamp
        new_tokens = elapsed * self.rate
        if new_tokens:
            self.tokens = min(self.capacity, self.tokens + new_tokens)
            self.timestamp = now

    def allow(self, cost=1):
        """
        Try to consume `cost` tokens.
        Returns True if allowed, False otherwise.
        """
        now = time.time()
        with self.lock:
            self._refill(now)
            if self.tokens >= cost:
                self.tokens -= cost
                return True
            return False
Enter fullscreen mode Exit fullscreen mode

Why this feels like leveling up

  • Continuous refill_refill runs on every call, so we never waste time waiting for a hard reset.
  • Burst friendly – starting with a full bucket lets us absorb spikes up to capacity instantly.
  • Thread‑safe – a simple lock keeps the state consistent under concurrent calls (you could swap it for a lock‑free atomic if you need extreme performance).
  • Configurable – tune rate for the long‑term average and capacity for how aggressive you want bursts to be.

Common pitfalls (the “traps” on the quest)

  1. Forgetting to update the timestamp – if you add tokens but don’t move self.timestamp forward, you’ll keep refilling the same elapsed time over and over, causing the bucket to overflow incorrectly.
  2. Using integers for rates – if rate is a fraction (e.g., 0.5 tokens/sec) and you store everything as ints, you’ll lose precision. Keep them as floats or use a fixed‑point representation.

A quick sanity check:

limiter = TokenBucketLimiter(rate=10, capacity=20)   # 10 req/s avg, up to 20 burst
for i in range(30):
    if limiter.allow():
        print(f"Request {i}: allowed")
    else:
        print(f"Request {i}: throttled")
    time.sleep(0.05)  # 20 req/s simulated load
Enter fullscreen mode Exit fullscreen mode

You’ll see the first 20 requests zip through, then the limiter starts spacing them out at roughly 10 per second — exactly what we wanted.

Why This New Power Matters

Adopting the token bucket changed how we think about protection. Instead of bluntly slamming the door after an arbitrary count, we now guide traffic, letting honest users enjoy smooth bursts while still keeping abusive or accidental spikes in check. The payoff:

  • Predictable latency – clients see fewer random 429s and more steady responses.
  • Better resource utilization – our servers aren’t idle during the “quiet” part of a fixed window, nor overwhelmed during the burst part.
  • Simpler tuning – two intuitive parameters (rate, capacity) replace the magic of window length and reset logic.

It’s like we gave our API a lightsaber instead of a blunt stick — precise, elegant, and ready for any duel the internet throws at it.

The Challenge

Your turn! Grab your favorite language and implement a token bucket limiter. Try experimenting with different rate and capacity values to see how they affect burst behavior under a simulated load. Share your results, or better yet, build a small middleware for your web framework and let it run in production.

What trade‑offs did you notice when you pushed the bucket to its limits? Drop a comment below — I’d love to hear how your quest went! 🚀

Top comments (0)