Code a rate limiter with swappable algorithms
Open in the interactive app: practice, quiz, mock interview →In one minute
- Put every algorithm behind one small interface,
allow(key) -> bool. Callers (HTTP middleware, a job queue, an outbound API client) never learn whether a token bucket or a sliding window sits behind it. - Keep state per key (user, API key, IP, tenant). Create it lazily on first use and keep only a few numbers per key. A token bucket stores tokens and a last-refill time. A sliding-window counter stores a window start and two counts.
- Inject the clock. Time is an input to the algorithm, not something the algorithm reads from the wall. An injected clock makes every test deterministic and every boundary case reproducible.
- Refill lazily. Nothing runs in the background. Each call computes how much time has passed since the last call and updates the state in O(1).
- The in-process design is the building block for the distributed one. The same two algorithms move into Redis or a sidecar once several servers have to share one budget.
Requirements
Functional:
allow(key)returns true if the request may proceed and records it. Otherwise it returns false and records nothing.- Support at least two strategies: a token bucket (steady rate with bursts) and a sliding-window counter (at most L requests per rolling window).
- Each key has its own independent budget.
- Limits are set at construction: capacity and refill rate, or limit and window length.
Non-functional:
- O(1) time per call and O(1) memory per active key.
- Safe under concurrent calls from many threads or goroutines.
- Deterministic and testable: no sleeps and no direct reads of the system clock.
- Cheap enough to run on every request. The limiter must never be the bottleneck it exists to prevent.
Out of scope: sharing limits across servers, persisting state across restarts, and per-route configuration loading. Extensibility covers the first.
Entities and relationships
- RateLimiter (interface):
allow(key). This is the only thing callers depend on. - TokenBucketLimiter: holds
capacity,refillPerSecond, a clock and a map from key to bucket. - Bucket:
tokens(a float, because refill is fractional) andlastRefillMs. - SlidingWindowLimiter: holds
limit,windowMs, a clock and a map from key to window. - Window:
start,previousCountandcurrentCount. - Clock: a function or interface returning the current time in milliseconds. Production passes a monotonic clock. Tests pass a fake that they advance by hand.
- LimiterPolicy (optional): maps a request to a key and a limiter. For example, "per API key, 100 per minute" and "per IP, 20 per second" can both apply, and the request passes only if every limiter allows it.
Class diagram
classDiagram
class RateLimiter {
<<interface>>
+allow(key) bool
}
class Clock {
<<interface>>
+nowMs() long
}
class TokenBucketLimiter {
-int capacity
-double refillPerSecond
-Map buckets
+allow(key) bool
}
class SlidingWindowLimiter {
-int limit
-long windowMs
-Map windows
+allow(key) bool
}
class Bucket {
+double tokens
+long lastRefillMs
}
class Window {
+long start
+int previousCount
+int currentCount
}
class CompositeLimiter {
-List limiters
+allow(key) bool
}
RateLimiter <|.. TokenBucketLimiter
RateLimiter <|.. SlidingWindowLimiter
RateLimiter <|.. CompositeLimiter
TokenBucketLimiter --> Clock
SlidingWindowLimiter --> Clock
TokenBucketLimiter *-- Bucket
SlidingWindowLimiter *-- Window
CompositeLimiter o-- RateLimiterKey flows
A token-bucket check, with the lock held for the whole read-modify-write:
sequenceDiagram
participant MW as Middleware
participant TB as TokenBucketLimiter
participant C as Clock
participant M as Bucket map
MW->>TB: allow("api-key-42")
TB->>TB: lock
TB->>C: nowMs()
C-->>TB: 10500
TB->>M: get or create bucket
M-->>TB: tokens 0.2, last 10000
TB->>TB: tokens = min(cap, 0.2 + 500 ms x rate)
TB->>TB: if tokens >= 1 then take one
TB->>TB: unlock
TB-->>MW: true or false
MW-->>MW: forward, or reply 429 with Retry-AfterThe token bucket starts each key full. Every call adds elapsed × rate tokens, caps the total at capacity, and spends one token if at least one is there. Capacity sets the largest burst and the rate sets the long-run average. A bucket with capacity 10 and 5 tokens per second allows a burst of 10 and then 5 per second.
The sliding-window counter splits time into fixed windows but smooths the edge between them. It estimates the count over the last windowMs as previousCount × (windowMs − elapsedInCurrent) / windowMs + currentCount. Take a limit of 4 per second. A key that made 4 requests in the previous second and is now 500 ms into the current one has an estimate of 4 × 0.5 = 2. Two more requests fit. A plain fixed window would let 4 more through right after the boundary, which doubles the rate across the edge. A sliding log fixes that exactly by storing every timestamp, but it costs O(L) memory per key. The counter version costs two integers and is accurate to within a few percent when traffic is spread fairly evenly across the window.
Design patterns used and why
- Strategy.
RateLimiteris the strategy interface. Token bucket, sliding-window counter, sliding log, fixed window and leaky bucket are interchangeable. The choice is policy (bursty or smooth), and the caller shouldn't care. - Dependency injection for time. The clock is a constructor argument. A limiter that calls
time.now()inside can only be tested with sleeps, which are slow and flaky. - Composite.
CompositeLimiterholds several limiters and allows a request only if all of them do. Per-second and per-day quotas then stack without new classes. Watch the subtle part: if the second limiter rejects, the first has already spent a token. Either accept that small over-count or add a two-phasecheckandcommit. - Lazy initialisation and a registry per key. Buckets are created on first use inside the map. The map is a registry, so it also needs an eviction policy (see the next section).
- Decorator at the edge. HTTP middleware wraps a handler and consults the limiter, so the limiter stays free of HTTP and can also guard a queue consumer.
Concurrency and edge cases
allow is a read-modify-write: read tokens, compute, write tokens. Two threads that both read 1 token can both spend it. That is the classic contention shape, the same one that oversells the last concert seat. The simplest correct fix is one mutex around the whole operation, which the Go practice uses. Under very high concurrency a single lock becomes a hot spot. Two well-known escalations:
- Shard the map into, say, 64 stripes, each with its own lock, chosen by hashing the key. Different keys stop contending with each other.
- Pack a bucket's state into one 64-bit word and update it with compare-and-swap. This is lock-free but harder to read.
Edge cases worth naming:
- The clock moves backwards. Wall clocks jump under NTP and manual changes. Use a monotonic clock, and still guard against a negative elapsed time: refill nothing, and never move
lastbackwards. - Long idle periods. Cap the refill at capacity, or a key idle for a day will build up a huge burst.
- Unbounded keys. An attacker cycling through random IPs can grow the map forever. Evict keys whose bucket is full, or whose windows are both empty, after an idle period. Or use a bounded LRU and accept that an evicted key comes back with a fresh budget.
- Floating-point drift. Fractional tokens are fine, but compare against 1 and cap at capacity so drift can't grow over time.
- Invalid configuration. A capacity below 1, or a rate or window of zero or less, should fail at construction, not on the first request.
Down the stack
Layer 1The contract
For a token bucket with capacity C and rate R, any interval of length T admits at most C + R×T requests per key. Each call is O(1), with no background work and no allocation after the key's first request. For the sliding-window counter, the guarantee is approximate. It never admits more than L in a single fixed window, and it smooths the boundary using an estimate that assumes requests were spread evenly across the previous window.
Layer 2The mechanism
Lazy refill is the central trick. Instead of a timer adding tokens every 1/R seconds, which means one timer per key and millions of wakeups, the bucket stores the time of its last update and catches up arithmetically. Go's golang.org/x/time/rate.Limiter works this way: it stores tokens, last and lastEvent under a mutex, and also offers Reserve and Wait for callers that want to queue instead of being rejected. Envoy's local rate limit filter is a token bucket configured with max_tokens, tokens_per_fill and fill_interval. The sliding-window counter is the approach Cloudflare described for counting requests across a large edge network, because it needs two counters per key instead of a log.
Layer 3The machine
A bucket is about 16 bytes of payload: a float64 and an int64. In Go, a map[string]*bucket entry adds the key string, a pointer and map overhead, so roughly 80–100 bytes per active key. A million keys costs about 100 MB, which is why eviction matters. An uncontended sync.Mutex lock and unlock costs around 10–20 ns. Under contention the cache line holding the mutex bounces between cores, at around 100 ns per transfer, and goroutines may park. That is why striping helps once you pass a few million calls per second on one process. Reading a monotonic clock through the vDSO costs about 20 ns, cheap enough to do on every call.
Why it's built this way
- Overload has to be stopped before the expensive work starts, so the check must cost far less than the request it guards.
- To be that cheap it can't run timers or scan logs, so state is a few numbers per key, updated lazily from elapsed time.
- Elapsed time makes the algorithm depend on a clock, so the clock is injected. That keeps production monotonic and tests deterministic.
- Many callers hit the same key at once, so each update is atomic under a lock. When one lock is too hot, you shard it.
Extensibility
- Distributed limits. When ten servers must share one budget per user, move the state to a shared store. In Redis, a Lua script performs the token-bucket read-modify-write atomically on one key, at the cost of one round trip (about 0.2–1 ms in the same zone) per request. To cut that cost, allocate a local slice of the budget to each server and sync it periodically, accepting a small over-admit. Or run a central limit service, as Envoy's global rate limiting does. The interface does not change. A
RedisTokenBucketis one moreRateLimiter. - Weighted requests. Add
allow(key, cost)so an expensive query spends 10 tokens. - Retry hints. Return a decision object with
allowed,remainingandretryAfterMs, so the middleware can setRetry-Afterand theRateLimitheaders being standardised by the IETF. - Queue instead of reject. A leaky bucket or
Reserve-style API delays work instead of dropping it, which suits outbound calls to a partner API.
Leadership lens
- Decide early what the limit protects: your capacity (a global and per-instance concern) or fairness between tenants (a per-key concern). The answer chooses the key, the algorithm and whether you need distribution at all.
- Prefer local, in-process limiting as the first line. It is fast, has no extra dependency and degrades gracefully. Add a shared limiter only where the business needs an exact global quota, such as a paid API tier.
- Make rejections observable. Emit allowed and rejected counts per key class, and alert on sudden spikes. A misconfigured limiter looks exactly like an outage to customers.
- Agree on fail-open versus fail-closed with product and security before launch, and test it. A rate limiter that takes the site down when Redis blips has made things worse.
Check yourself
A token bucket has capacity 10 and refills at 2 tokens per second. A key has been idle for an hour. How many requests can it make in the next 3 seconds?
Show answer
Up to 16: 10 from the full bucket immediately, plus 2 per second for 3 seconds. The idle hour adds nothing beyond the cap.
Why does the sliding-window counter need the previous window's count at all?
Show answer
Without it, the limiter forgets everything at each boundary, so a client can send L requests just before the boundary and L just after. Weighting the previous count by how much of it still overlaps the rolling window closes that gap.
Why inject a clock instead of calling the system time inside
allow?Show answer
The algorithm's output depends on time, so time is an input. Injecting it makes tests deterministic and fast, and lets production choose a monotonic source that never jumps backwards.
Two goroutines call
allowon a bucket holding exactly one token. Without a lock, what can happen, and why?Show answer
Both read one token, both decide to allow, and both write zero, so two requests pass. The read-modify-write must be atomic, under a mutex or with a compare-and-swap loop.
Watch
- Design Rate Limiter (LLD): Token Bucket, Fixed and Sliding Window with Thread Safety (Shubh Patel): a class-level design with interchangeable strategies and locking.
- Rate Limiting system design: Token Bucket, Leaky Bucket, Sliding Logs (Tech Dummies): the main algorithms compared, then how they extend to a distributed setup.
- Rate Limiting in Golang: Token Bucket and Sliding Window from Scratch (Arslan Ametov): both algorithms implemented in Go.
Free resources
- golang.org/x/time/rate (Go package docs): a production token bucket with
Allow,ReserveandWait. Its source is short and worth reading. - Scaling your API with rate limiters (Stripe): the four kinds of limiters Stripe runs and how they roll them out safely.
- How we built rate limiting capable of scaling to millions of domains (Cloudflare): the sliding-window counter approximation and its measured error.
- Local rate limit filter (Envoy docs): a token bucket configured in a real proxy.
- RateLimit header fields for HTTP (IETF draft): how to tell clients their remaining quota and when to retry.
- Token bucket (Wikipedia): background, including the relationship to the leaky bucket.