Rate limiting is not only for blocking bots. It protects databases and dependencies from bursts, distributes resources fairly between tenants, controls API costs, and helps a system degrade predictably under load. A poorly designed limiter can instead block thousands of legitimate users behind NAT or become a new single point of failure.
This guide moves from algorithm selection to distributed Redis enforcement, with the goal of a policy that is explainable, observable, and consistent across application instances.
Rate limits, quotas, and concurrency limits
- Rate limit: requests within an interval, such as 100 per minute.
- Quota: total usage over a longer billing cycle.
- Concurrency limit: operations currently in progress.
A file-upload API may need all three: rate limiting for spam, quota for cost, and concurrency control for CPU and I/O. A minute counter should not replace every mechanism.
1. Define the goal before choosing an algorithm
Identify the protected resource, the subject (user, API key, tenant, IP, or device), acceptable burst size, regional consistency requirement, and failure behavior when Redis is unavailable. Tie policy to cost: a cached read should not consume the same budget as report export or AI inference.
2. Choose safe identities and keys
Prefer authenticated tenant, user, or API-key IDs. IP is secondary because NAT may group an entire office while attackers can rotate proxies.
rl:v1:{tenant_id}:{route_group}:{window}
Do not place raw API keys, emails, or personal data in Redis keys or logs. Trust X-Forwarded-For only from controlled proxies. Production commonly layers coarse IP limits at CDN/WAF, user or API-key limits at the gateway, tenant budgets, and global dependency protection.
3. Fixed window: simple with boundary bursts
bucket = floor(current_time / 60)
key = "rl:user:42:" + bucket
count = INCR(key)
EXPIRE(key, 120)
allow = count <= 100
This is O(1), memory-efficient, and operationally simple. A client can still send 100 requests at the end of one minute and another 100 at the beginning of the next. Execute INCR and EXPIRE atomically through a transaction or script so a crash cannot leak a permanent key.
4. Sliding window log: accurate but memory-intensive
ZREMRANGEBYSCORE key 0 (now - window)
ZADD key now unique_request_id
ZCARD key
EXPIRE key window
A sorted set stores each request timestamp. It provides an accurate rolling window but costs memory proportional to traffic and needs unique members for same-millisecond requests. Use it for sensitive moderate-volume endpoints, not millions of continuously active keys.
5. Sliding window counter: a practical compromise
estimated = current_count
+ previous_count * remaining_fraction
Interpolating current and previous counters uses less memory than a log and smooths boundaries better than a fixed window. It is approximate, which is often acceptable for general request quotas.
6. Token bucket: useful when legitimate bursts exist
A bucket has a capacity and refills at refill_rate. Requests consume one or more tokens. A capacity of 60 with one token per second allows a 60-request burst after idle time while sustaining one request per second over the long term. Expensive operations can cost more tokens.
7. Atomic token bucket with Redis Lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local values = redis.call('HMGET', key, 'tokens', 'updated_at')
local tokens = tonumber(values[1]) or capacity
local updated_at = tonumber(values[2]) or now_ms
local elapsed = math.max(0, now_ms - updated_at) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'updated_at', now_ms)
redis.call('PEXPIRE', key, math.ceil((capacity / refill_rate) * 2000))
return { allowed, math.floor(tokens) }
The read–decide–write cycle must be atomic so concurrent requests cannot spend the same token. Use a consistent clock or Redis time to reduce skew. Keep scripts short because Redis blocks other activity while they execute. In Redis Cluster, all keys in one invocation must share a hash slot; one key per subject keeps this manageable.
8. Where should enforcement live?
- CDN/WAF: blocks bots and volumetric abuse early but knows little business identity.
- API gateway: centralizes API-key and tenant policies.
- Application: understands users, routes, and business cost.
- Downstream service: protects its own capacity from faulty internal callers.
Use layers. An application limiter is not DDoS protection because traffic already reached the stack; a CDN limiter cannot replace a business tenant quota.
9. Return a useful HTTP 429 response
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 12
{
"type": "https://api.example.com/problems/rate-limit",
"title": "Rate limit exceeded",
"status": 429,
"retry_after": 12
}
Retry-After tells a client when to retry. RFC 9333 defines RateLimit fields for limit, remaining quota, and reset; keep their semantics consistent when supported and never treat headers as a security control.
Clients should honor Retry-After, use exponential backoff with jitter, cap retries, and avoid synchronizing every retry at the same instant.
10. Treat failure classes deliberately
The limiter usually runs before the handler, so an allowed request consumes budget even if the response is 4xx or 5xx. Refunding tokens adds races and can be abused. Instead, design separate budgets: strict account-plus-IP limits for failed logins, charge validation failures to deter spam, give health checks a distinct policy, and use concurrency/backpressure for queued work.
11. Redis failure: fail open or fail closed?
Fail-open allows requests when the limiter is unavailable. It preserves availability for low-risk reads but may overload dependencies. Fail-closed rejects requests and fits login, OTP, payment, or hard cost controls, but makes Redis part of endpoint availability.
A hybrid can combine very short Redis timeouts, a local emergency limiter per instance, a circuit breaker, and route-specific policy. Never bypass silently; emit metrics and alerts whenever fallback activates.
12. Hot keys, memory, and multiple regions
One global key can become hot. Distribute limits by tenant and route, or allocate regional budgets when perfect global consistency is unnecessary. Strong multi-region synchronization increases latency; independent regional budgets can temporarily exceed the global target, so reserve a safety margin.
Every temporary key needs TTL. Monitor memory, eviction, command latency, and hit rate. If limiter keys are evicted as ordinary cache entries, clients may receive an early budget reset.
13. Observability
- Allowed and rejected requests by route, plan, and region.
- 429 ratio and number of subjects reaching limits.
- Redis latency, timeout, errors, memory, and eviction.
- Lua/function execution time and hot keys.
- Fail-open, fail-closed, and local-fallback activations.
- Correlation between rejection, downstream latency, and saturation.
Avoid raw user IDs in high-cardinality metrics. Sample logs and hash identities; keep dashboard dimensions bounded.
14. Test before production
- Unit-test window boundaries, refill math, and weighted cost.
- Concurrency-test many requests against one key.
- Load-test many keys and a single hot key.
- Chaos-test Redis timeout, failover, and connection-pool exhaustion.
- Verify TTL, memory growth, and cleanup.
- Test client behavior for 429, Retry-After, and jitter.
- Canary a high threshold in observation-only mode before enforcement.
Design checklist
- Policy reflects real resource cost.
- Identity favors user, API key, or tenant rather than IP alone.
- The algorithm matches burst and accuracy requirements.
- Redis decisions are atomic and every key expires.
- 429 responses provide consistent retry guidance.
- Failure behavior is route-specific and observable.
- Limits are versioned, feature-flagged, and reversible.
- Dashboards connect rejection to dependency health.
Conclusion
Good rate limiting coordinates capacity rather than placing an arbitrary number in front of an API. Start with identity and cost, choose fixed window, sliding window, or token bucket according to burst behavior, enforce decisions atomically in Redis, return useful 429 responses, and plan for Redis failure itself. Observable policies and gradual rollout protect the system without unnecessarily punishing legitimate users.




No comments yet. Be the first to share your thoughts.