Any public-facing API requires rate limiting. Without it, your system is vulnerable to brute-force attacks, DDoS attempts, and noisy neighbor problems where a single aggressive client degrades the experience for everyone else.
Designing a rate limiter for a single server is relatively simple. Designing one for a globally distributed microservices architecture handling millions of requests per second is a classic system design challenge. In this article, we'll break down the architecture and algorithms required.
1. Choosing the Right Algorithm
The heart of a rate limiter is the algorithm it uses to count requests.
Token Bucket
How it works: Imagine a bucket holding tokens. Tokens are added at a fixed rate. Every request costs one token. If the bucket is empty, the request is dropped.
Pros: Memory efficient, allows for short bursts of traffic.
Cons: Tricky to tune burst size vs. sustained rate.
Fixed Window Counter
How it works: Divides time into fixed windows (e.g., 12:00 to 12:01). Increments a counter for every request. Resets at the start of the next window.
Pros: Very easy to implement.
Cons: Spike traffic at the edges of the window. A client could send their entire quota at 12:00:59 and again at 12:01:01, effectively doubling their allowed rate in a short span.
Sliding Window Log
How it works: Keeps a timestamp log of every request for a user. Drops requests if the log size exceeds the limit within the time frame.
Pros: Highly accurate.
Cons: Consumes significant memory and compute to maintain and prune logs. Not viable for massive scale.
Sliding Window Counter (The Winner)
How it works: A hybrid approach. It tracks the counter for the previous fixed window and the current fixed window, and calculates a weighted average based on the current time overlapping the window.
Pros: Smooths out edge spikes, highly memory efficient, highly accurate.
Advertisement
2. Architecture for Distributed Systems
In a multi-server setup, maintaining state becomes the primary challenge. If a load balancer routes User A's first request to Server 1 and their second to Server 2, how do they share the rate limit counter?
Option A: Sticky Sessions
Route all traffic for a specific user to the same server. The server keeps the rate limit state in local memory.
Verdict: Bad idea. It unevenly distributes load and fails gracefully when a server crashes.
Option B: Centralized Datastore (Redis)
All servers read and write to a centralized Redis cluster. Redis is fast enough (in-memory) to handle the latency requirements.
Verdict: The standard approach. However, it introduces network latency and race conditions.
3. Handling Race Conditions in Redis
If two requests for the same user hit two different API servers simultaneously, both might read the counter as 4, both increment to 5, and both allow the request, bypassing the limit.
Solution: Lua Scripts
Redis allows you to execute Lua scripts atomically. You can write a script that fetches the counter, checks the limit, increments it, and updates the TTL in a single atomic operation.
lua
-- Simple Token Bucket Lua Script
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then
return 0 -- Rejected
else
redis.call('INCRBY', key, 1)
redis.call('EXPIRE', key, 60)
return 1 -- Allowed
end
4. Performance Optimization: Local Cache + Async Sync
For ultra-high throughput where even Redis latency is unacceptable, you can use a hybrid approach.
API servers keep a local, in-memory rate limiter.
They sync their local counts to the central Redis cluster asynchronously every few seconds.
Tradeoff: This trades strict accuracy for performance. A client might exceed their limit slightly between sync windows, but the system remains highly available and fast.
Architectural Insights & Key Takeaways
Building a distributed rate limiter requires balancing strict enforcement against system latency. For most modern APIs, combining a Sliding Window algorithm implemented via atomic Redis Lua scripts provides the best balance of accuracy, performance, and scalability.
Fixed Window counters allow burst traffic spikes at boundary edges: a user can exhaust their full quota in the last second of window A and immediately send another full quota in the first second of window B. Sliding Window Counter calculates a weighted average between the previous and current window, smoothing boundary bursts with minimal memory overhead (two integer keys per client).
A standard GET followed by an INCR command creates a race condition where concurrent worker servers both read the same counter value before incrementing, allowing clients to exceed limits. Executing a Lua script runs atomically on the single-threaded Redis engine, guaranteeing that threshold verification, incrementing, and TTL expiration occur as an indivisible operation.
High-availability architectures implement circuit breakers with configurable fail-open or fail-closed modes. Standard read/query APIs fail open when Redis times out (>5ms) to preserve user uptime, whereas security-critical endpoints (such as login password verification, MFA verification, and credit card processing) fail closed to block brute-force attacks.
Synchronizing every API hit across multi-region Redis instances introduces intolerable 100ms+ cross-region network latency. Leading architectures partition the global quota across regions (for example, 40% to US-East, 30% to EU-Central, 30% to AP-Southeast) and synchronize counter deltas asynchronously in background batches using message brokers.
Return HTTP 429 Too Many Requests along with standard rate limit headers: RateLimit-Limit (total quota allowed in window), RateLimit-Remaining (available tokens in current window), RateLimit-Reset (seconds until quota resets), and Retry-After (minimum delay in seconds before clients should retry).
Share this article:
Free Developer Utilities
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Master modern database sharding architectures: horizontal partitioning, range vs consistent hash keys, cross-shard joins, distributed transactions (2PC vs Saga), Vitess, and Citus.