Quizzr Logo

Rate Limiting

Implementing Token Bucket Algorithms for Flexible API Bursting

Learn how to use the token bucket pattern to allow controlled traffic bursts while maintaining a steady long-term request rate. This article covers the mathematical refill logic and memory-efficient storage patterns for tokens.

Backend & APIsIntermediate12 min read

The Challenge of Unpredictable API Traffic

Modern web services operate in an environment where traffic patterns are rarely stable or predictable. A sudden surge in requests can originate from a marketing campaign, a batch processing job, or even a malicious actor attempting a denial of service attack. Without a control mechanism, these spikes can exhaust database connections, saturate CPU cycles, and eventually lead to a total system outage.

Traditional scaling methods like adding more compute resources often take too long to react to instantaneous bursts. This creates a dangerous window where the service is vulnerable to cascading failures. Rate limiting serves as a critical circuit breaker that protects the integrity of your infrastructure while ensuring that a single user does not monopolize shared resources.

The primary goal of any rate limiting strategy is to balance system stability with a positive user experience. Strict limits might prevent legitimate bursts of activity, while loose limits risk resource exhaustion. The token bucket algorithm has emerged as the industry standard because it gracefully handles these competing requirements.

Rate limiting is not just about blocking bad actors; it is about providing a predictable and fair environment for every legitimate request your system handles.

Engineers often struggle with the trade-off between strictness and flexibility. If you implement a window that is too rigid, you might block a mobile application that needs to synchronize several data points at startup. Conversely, allowing too much freedom can leave your backend vulnerable to memory pressure during peak hours.

The Limitation of Fixed Windows

Before exploring the token bucket, it is important to understand why simpler methods like the fixed window counter often fail. In a fixed window system, you reset the request count at the start of every minute or hour. This creates a boundary problem where a user can double their allowed quota by sending requests at the very end of one window and the very beginning of the next.

This burst at the edge of a window can create a spike that is twice as large as your system intended to handle. Such spikes are often enough to trigger auto-scaling events or saturate narrow bottlenecks like third-party API keys. The token bucket solves this by smoothing out the refill process over time rather than relying on arbitrary clock boundaries.

Mental Model: The Token Bucket Analogy

To understand the token bucket, imagine a literal bucket that holds tokens, where each token represents the permission to perform a single API request. The bucket has a maximum capacity, meaning it can only hold a certain number of tokens at any given time. When a request arrives, the system checks if there is at least one token available in the bucket.

If a token is available, it is removed and the request is processed immediately. If the bucket is empty, the request is rejected with a 429 Too Many Requests status code. This simple check-and-remove cycle ensures that the system never processes more requests than it has tokens for.

The core intelligence of this pattern lies in the refill mechanism. A separate process or logic adds tokens back into the bucket at a constant, predefined rate until the bucket reaches its maximum capacity. This allows for a steady stream of requests while still permitting a defined amount of bursting when the bucket is full.

  • Bucket Capacity: The maximum number of tokens the bucket can hold, defining the largest possible burst size.
  • Refill Rate: The number of tokens added to the bucket per second or minute, defining the long-term sustained request rate.
  • Token Cost: Usually one token per request, though complex operations could theoretically cost more.

This separation of the burst limit from the sustained rate is what makes the token bucket so powerful. It allows developers to configure a system that can handle an initial rush of activity without allowing that rush to continue indefinitely. It mimics the way real-world resources like network bandwidth or CPU time are consumed and replenished.

Defining Burstiness versus Throughput

Throughput refers to the average number of requests your system can handle over a long duration. In the token bucket model, this is strictly controlled by the refill rate. If you add ten tokens per second, your long-term throughput is capped at ten requests per second regardless of how large your bucket is.

Burstiness refers to the ability to handle a high volume of requests in a very short interval. This is controlled by the bucket capacity. A bucket with a capacity of one hundred tokens allows a user to send one hundred requests simultaneously, provided they have not used the service for a while and allowed the tokens to accumulate.

Implementing the Logic Efficiently

A common mistake when implementing this pattern is creating a dedicated background thread or cron job to refill tokens for every user. In a system with millions of users, managing millions of timers is computationally expensive and scales poorly. Instead, we can use a lazy evaluation strategy based on timestamps.

When a request arrives, we calculate how many tokens should have been added since the last time the user made a request. This calculation involves taking the current time, subtracting the last refill timestamp, and multiplying that duration by the refill rate. This approach is highly efficient because it only performs work when a request is actually occurring.

Once the new tokens are calculated, we add them to the existing balance and cap the result at the maximum bucket capacity. Then we check if there are enough tokens for the current request. This entire operation requires only two pieces of data per user: the current token count and the timestamp of the last update.

pythonIn-Memory Token Bucket Implementation
1import time
2
3class TokenBucket:
4    def __init__(self, capacity, refill_rate):
5        self.capacity = float(capacity)
6        self.refill_rate = float(refill_rate)  # tokens per second
7        self.tokens = float(capacity)
8        self.last_update = time.monotonic()
9
10    def consume(self, amount=1):
11        now = time.monotonic()
12        # Calculate time passed since last request
13        time_passed = now - self.last_update
14        
15        # Add tokens based on elapsed time
16        new_tokens = time_passed * self.refill_rate
17        self.tokens = min(self.capacity, self.tokens + new_tokens)
18        self.last_update = now
19
20        if self.tokens >= amount:
21            self.tokens -= amount
22            return True
23        return False
24
25# Usage for a high-traffic API endpoint
26limiter = TokenBucket(capacity=10, refill_rate=2)
27if limiter.consume():
28    # Process the API request
29    pass

The memory footprint of this implementation is minimal, making it suitable for high-performance applications. However, using local memory only works if your application consists of a single instance. In a modern distributed architecture with multiple load-balanced servers, you need a centralized state store.

Mathematical Refill Logic

The calculation for the new token count must be handled with care regarding floating point precision. If the refill rate is very small, a significant amount of time might need to pass before a full token is generated. Using floating point numbers for the token count allows for fractional tokens, which ensures that no time period is wasted.

You must also ensure that the last update timestamp is updated even if the request is rejected. This prevents the bucket from refilling multiple times for the same time interval during a high-frequency attack. By updating the timestamp on every check, you maintain a consistent timeline for token generation.

Distributed Rate Limiting with Redis

In a distributed environment, storing the bucket state in the memory of an application server is insufficient. A client might hit Server A for one request and Server B for the next, effectively doubling their allowed quota. To solve this, we move the token count and timestamp storage to a shared database like Redis.

Moving state to an external store introduces the risk of race conditions. If two application servers read the token count at the same time, they might both see one token available and both proceed to consume it. This results in the client exceeding their limit because the two servers were not synchronized.

To prevent this, we use Lua scripts within Redis. Lua scripts are executed atomically, meaning no other command can run in the middle of the script execution. This allows us to perform the calculate, update, and consume logic in a single atomic step that is safe from race conditions.

luaAtomic Redis Rate Limiter Script
1-- KEYS[1]: The user's rate limit key
2-- ARGV[1]: Current timestamp in seconds
3-- ARGV[2]: Refill rate (tokens per second)
4-- ARGV[3]: Bucket capacity
5-- ARGV[4]: Tokens to consume
6
7local data = redis.call('HMGET', KEYS[1], 'tokens', 'last_update')
8local tokens = tonumber(data[1]) or tonumber(ARGV[3])
9local last_update = tonumber(data[2]) or tonumber(ARGV[1])
10
11-- Calculate and add new tokens
12local time_passed = math.max(0, tonumber(ARGV[1]) - last_update)
13local added_tokens = time_passed * tonumber(ARGV[2])
14tokens = math.min(tonumber(ARGV[3]), tokens + added_tokens)
15
16-- Check if we can consume
17if tokens >= tonumber(ARGV[4]) then
18    tokens = tokens - tonumber(ARGV[4])
19    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_update', ARGV[1])
20    return 1
21else
22    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_update', ARGV[1])
23    return 0
24end

By offloading this logic to Redis, you ensure that all instances of your API share the same truth regarding a user's usage. Redis is particularly well-suited for this task because it is an in-memory store that offers the extremely low latency required for a middleware component that runs on every request.

Handling High Cardinality

When dealing with millions of users, the number of keys in your Redis instance can grow rapidly. It is important to set an expiration time on your rate limit keys so that inactive users do not consume memory indefinitely. Typically, an expiration time equal to the time it takes to fill an empty bucket is sufficient.

Additionally, consider the network overhead between your application and Redis. If your API handles thousands of requests per second, the latency of a Redis call can become a bottleneck. Using a local cache for very aggressive limits or batching updates can help alleviate this pressure in extreme scenarios.

Operational Best Practices

Implementing the algorithm is only half the battle; the other half is communicating limits to your users. Standard practice involves including headers in your API response that inform the client of their current status. These usually include the remaining tokens and the time until the next refill.

Headers like X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset help developers build client-side backoff logic. When a client knows they are reaching their limit, they can proactively slow down their request rate. This reduces the number of 429 errors and provides a smoother experience for the end user.

Finally, you should always monitor your rate limiting metrics. If you see a high volume of 429 errors for legitimate users, your limits may be too restrictive. Conversely, if your backend services are struggling while your rate limiter shows plenty of capacity, you likely need to tune your bucket sizes downward.

Remember that rate limiting is a dynamic process. As your infrastructure evolves and your application code becomes more or less efficient, your traffic thresholds will need to be adjusted. Regular load testing is essential to find the breaking point of your system and set limits accordingly.

Tiered Rate Limiting

Not all users should be treated the same. A common strategy is to implement tiered rate limiting based on a user's subscription level or authentication status. Unauthenticated users might have very small buckets, while premium enterprise users are granted much larger capacities.

You can also implement different buckets for different types of operations. Read-only operations that are heavily cached might have high limits, while expensive write operations that trigger background jobs might have much lower thresholds. This granular control protects your most expensive resources while keeping the fast parts of your API open.

We use cookies

Necessary cookies keep the site working. Analytics and ads help us improve and fund Quizzr. You can manage your preferences.