DEV Community

Timevolt
Timevolt

Posted on

Designing a Rate Limiter: A Journey Inspired by Inception

The Quest Begins (The “Why”)

I still remember the first time I got paged at 3 a.m. because our API was getting hammered by a rogue script. The logs showed a spike of 50 k requests per second—far beyond what our servers could handle. We scrambled, threw more instances at the problem, and watched the cost skyrocket. It felt like trying to stop a tsunami with a sandbag. After the fire was out, I sat down with a coffee and asked myself: “Is there a smarter way to say ‘no’ before the flood even starts?” That question kicked off a deep dive into rate limiting, and what I found felt like unlocking a secret level in a game—suddenly the chaos made sense.

The Revelation (The Insight)

The biggest “aha!” moment came when I realized that most beginners fixate on counting requests in a fixed time window (e.g., “allow 100 requests per minute”). It’s easy to code, but it creates a nasty edge case: a burst of traffic right at the window boundary can sneak through, letting double the allowed rate slip in for a brief moment. Worse, if the window resets while a client is still in the middle of a burst, they get unfairly throttled.

The insight that changed everything? Think of requests as tokens flowing through a bucket, not as a static counter. The token bucket algorithm gives you two knobs:

  1. Refill rate – how fast tokens are added (the sustainable rate).
  2. Bucket size – how many tokens can be stored (the burst capacity).

When a request arrives, you try to consume a token. If there’s one, the request goes through; if not, it’s rejected. The bucket continuously refills at the refill rate, so short bursts are allowed up to the bucket size, but over the long term you can’t exceed the sustainable rate. It’s smooth, fair, and easy to reason about.

Why does this beat the fixed‑window approach?

  • No boundary spikes – the bucket smooths out bursts naturally.
  • Simple math – just subtract and add; no need to store per‑window counters.
  • Easy to distribute – with a shared store like Redis, each service can read and update the same bucket atomically.

Wielding the Power (Code & Examples)

Let’s look at the struggle first—a naive fixed‑window limiter in Python (using a simple in‑memory dict for illustration):

# Naive fixed-window rate limiter (the struggle)
import time
from collections import defaultdict

class FixedWindowLimiter:
    def __init__(self, limit, window_sec):
        self.limit = limit
        self.window = window_sec
        self.hits = defaultdict(list)   # user -> timestamps

    def allow(self, user):
        now = time.time()
        window_start = now - self.window
        # prune old hits
        self.hits[user] = [ts for ts in self.hits[user] if ts >= window_start]
        if len(self.hits[user]) < self.limit:
            self.hits[user].append(now)
            return True
        return False
Enter fullscreen mode Exit fullscreen mode

Traps to avoid:

  • The prune step is O(n) per request—bad at scale.
  • If you forget to prune, the list grows forever, eating memory.
  • The window reset can let a client send limit * 2 requests in a spike that straddles the boundary.

Now, the victory—a token bucket limiter backed by Redis (so it works across multiple instances):

# Token bucket rate limiter (the victory)
import time
import redis

class TokenBucketLimiter:
    def __init__(self, redis_client, key, refill_rate_per_sec, bucket_capacity):
        self.redis = redis_client
        self.key = key
        self.rate = refill_rate_per_sec          # tokens added per second
        self.capacity = bucket_capacity          # max tokens in the bucket
        self.lua = """
            local now = tonumber(ARGV[1])
            local rate = tonumber(ARGV[2])
            local capacity = tonumber(ARGV[3])
            local requested = tonumber(ARGV[4])

            local last_ts = redis.call('HGET', KEYS[1], 'last_ts')
            local tokens = redis.call('HGET', KEYS[1], 'tokens')
            if not last_ts then
                last_ts = now
                tokens = capacity
            else
                last_ts = tonumber(last_ts)
                tokens = tonumber(tokens)
            end

            -- refill based on elapsed time
            local delta = now - last_ts
            tokens = math.min(capacity, tokens + delta * rate)
            local allowed = tokens >= requested
            if allowed then
                tokens = tokens - requested
            end
            redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_ts', now)
            redis.call('EXPIRE', KEYS[1], 3600)  // optional TTL to clean stale keys
            return allowed and 1 or 0
        """

    def allow(self, user, cost=1):
        now = time.time()
        key = f"{self.key}:{user}"
        # EVALSHA would be better in prod; we keep it simple for demo
        result = self.redis.eval(
            self.lua,
            1,
            key,
            now,
            self.rate,
            self.capacity,
            cost
        )
        return bool(result)
Enter fullscreen mode Exit fullscreen mode

What makes this shine?

  • The Lua script runs atomically inside Redis, guaranteeing that two concurrent requests can’t both think they have a token when there’s only one.
  • Refill is calculated on the fly from the last timestamp—no need to run a background cron job.
  • The bucket size lets you tolerate short spikes (think of a game’s special ability that lets you unleash a combo for a few seconds before cooldown kicks in).

ASCII Diagram – How the Token Bucket Works

   +-------------------+      refill (rate tokens/sec)      +-------------------+
   |   Request comes   |  ------------------------------>  |   Token Bucket    |
   |   (cost = 1)      |                                   |   (capacity = N)  |
   +-------------------+                                   +--------+----------+
            |                                                       |
            |  if token available?                                 |  else reject
            |                                                       |
            v                                                       v
   +-------------------+                              +-------------------+
   |   Request allowed |<-----------------------------|   Wait for refill |
   +-------------------+                              +-------------------+
Enter fullscreen mode Exit fullscreen mode

When a request arrives, we check the current token count. If enough tokens exist, we deduct them and let the request through. Otherwise, we refuse (or optionally make the client wait). The bucket constantly leaks tokens out at the refill rate, ensuring long‑term adherence to the sustainable limit.

Why This New Power Matters

Armed with the token bucket, you can now protect any service—APIs, webhooks, micro‑service endpoints—without over‑provisioning hardware. You get:

  • Predictable costs – you only pay for the baseline traffic you actually need.
  • Graceful burst handling – users won’t see sudden 429 errors during legitimate spikes (think of a flash sale or a breaking‑news event).
  • Operational simplicity – a single Redis key per user (or per API key) stores all the state; no rotating windows, no cleanup jobs.

Imagine you’re building a chatbot that gets a flood of messages when a meme goes viral. With a token bucket set to 10 msg/sec steady rate and a burst of 20, the bot stays responsive, serves the genuine users, and politely throttles the spammy scripts—all without you waking up at 3 a.m. again.

The Challenge

Your turn! Pick a service you own or a side project you’ve been meaning to protect. Implement a token bucket limiter (the Redis Lua version is a great starter, or even an in‑memory version for a single‑node prototype). Play with the refill rate and bucket size, watch how burst traffic behaves, and share your results in the comments. Did you find a sweet spot? Did you hit any unexpected edge cases? Let’s keep the quest going—because every limiter you tame makes the internet a little smoother for everyone. Happy coding!

Top comments (0)