rate limiting
Rate Limiting
Understand how rate limiting protects capacity, prevents abuse, and enforces fairness, which algorithm suits which workload, and how to make limits work correctly across a distributed fleet.
Prerequisites
Recommended Knowledge
- HTTP status codes and response headers
- API gateways and reverse proxies
- Authentication and client identity
- Distributed caches such as Redis
- Atomic operations and race conditions
- Retries, backoff, and jitter
- Latency percentiles and capacity planning
- Multi-tenant isolation concepts
What Rate Limiting Does
Rate limiting restricts how many operations a given caller may perform within a defined period. When the limit is exceeded, the request is rejected or delayed rather than served.
Here \(L\) is the permitted count and \(W\) is the window. The apparent simplicity is deceptive: the behaviour at window boundaries, across many servers, and under bursty traffic is where the real design work lies.
Simple Analogy
A motorway ramp meter releases vehicles at a controlled pace. It does not increase the road's capacity, but it prevents a surge from collapsing throughput for everyone already travelling.
Why Systems Need It
| Objective | Problem Addressed | Typical Scope |
|---|---|---|
| Capacity protection | Traffic surge exhausting resources | Per service or endpoint |
| Fairness | One tenant consuming shared capacity | Per tenant or account |
| Abuse prevention | Credential stuffing and scraping | Per identity and per address |
| Cost control | Expensive downstream calls | Per operation class |
| Dependency protection | Overwhelming a fragile backend | Per downstream service |
| Commercial tiering | Differentiating plan entitlements | Per subscription level |
Related but Distinct Controls
| Control | Question It Answers | Time Horizon |
|---|---|---|
| Rate limiting | How fast may this caller send requests? | Seconds to minutes |
| Quota | How much may this caller consume in total? | Days to months |
| Throttling | Should this request be slowed rather than rejected? | Per request |
| Load shedding | Is the system overloaded right now? | Immediate, system-wide |
| Concurrency limit | How many requests may be in flight at once? | Instantaneous |
| Circuit breaker | Is a dependency failing and should calls stop? | Until recovery |
Fixed Window Counter
The simplest approach divides time into fixed intervals and counts requests within each. When the count exceeds the limit, further requests are rejected until the window resets.
Advantages
- Trivial to implement and reason about
- Minimal memory, one counter per key
- Fast atomic increment operations
- Easy to explain to API consumers
The Boundary Problem
- Allows double the limit across a boundary
- Encourages synchronized client bursts
- Counter resets create traffic spikes
- Poor approximation of a true rate
A caller sending the full limit in the final moment of one window and again in the first moment of the next transmits twice the intended rate across a span shorter than a single window.
Sliding Window Log
This approach stores a timestamp for every request and counts those falling within the trailing window. It is exact, with no boundary artefact.
async function slidingWindowLog(key, limit, windowMs) {
const now = Date.now();
const windowStart = now - windowMs;
const redisKey = `ratelimit:log:${key}`;
const results = await redis
.multi()
.zremrangebyscore(redisKey, 0, windowStart)
.zcard(redisKey)
.zadd(redisKey, now, `${now}-${randomSuffix()}`)
.pexpire(redisKey, windowMs)
.exec();
const countBeforeAdd = results[1];
if (countBeforeAdd >= limit) {
await redis.zremrangebyscore(redisKey, now, now);
return {
allowed: false,
remaining: 0,
retryAfterMs: windowMs
};
}
return {
allowed: true,
remaining: limit - countBeforeAdd - 1,
retryAfterMs: 0
};
}
Sliding Window Counter
This hybrid approximates the sliding window by weighting the previous window's count according to how far the current window has progressed. It captures most of the accuracy at a fraction of the memory cost.
async function slidingWindowCounter(key, limit, windowMs) {
const now = Date.now();
const currentWindow = Math.floor(now / windowMs);
const previousWindow = currentWindow - 1;
const elapsedRatio = (now % windowMs) / windowMs;
const currentKey = `ratelimit:${key}:${currentWindow}`;
const previousKey = `ratelimit:${key}:${previousWindow}`;
const [currentCount, previousCount] = await redis.mget(
currentKey,
previousKey
);
const estimated =
Number(currentCount || 0) +
Number(previousCount || 0) * (1 - elapsedRatio);
if (estimated >= limit) {
return {
allowed: false,
remaining: 0,
resetMs: windowMs - (now % windowMs)
};
}
await redis
.multi()
.incr(currentKey)
.pexpire(currentKey, windowMs * 2)
.exec();
return {
allowed: true,
remaining: Math.floor(limit - estimated - 1),
resetMs: windowMs - (now % windowMs)
};
}
Token Bucket
A bucket holds tokens, refilled at a steady rate up to a maximum capacity. Each request consumes a token. An empty bucket means rejection.
Here \(B\) is bucket capacity, \(r\) is the refill rate, and \(\Delta t\) is elapsed time. Capacity determines burst tolerance while the refill rate determines sustained throughput.
const TOKEN_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local nowMs = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 'tokens', 'updatedAt')
local tokens = tonumber(state[1])
local updated = tonumber(state[2])
if tokens == nil then
tokens = capacity
updated = nowMs
end
local elapsed = math.max(0, nowMs - updated) / 1000
tokens = math.min(capacity, tokens + elapsed * refillRate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'updatedAt', nowMs)
redis.call('PEXPIRE', key, math.ceil(capacity / refillRate * 1000) + 1000)
return { allowed, tokens }
`;
async function tokenBucket(key, config, cost = 1) {
const [allowed, remaining] = await redis.eval(
TOKEN_BUCKET_SCRIPT,
1,
`ratelimit:bucket:${key}`,
config.capacity,
config.refillRatePerSecond,
Date.now(),
cost
);
return {
allowed: allowed === 1,
remaining: Math.floor(remaining)
};
}
Leaky Bucket
Requests enter a queue that drains at a constant rate. Overflow is rejected. The output rate is perfectly smooth, regardless of how irregular the input is.
| Property | Token Bucket | Leaky Bucket |
|---|---|---|
| Burst handling | Permitted up to capacity | Smoothed into a steady stream |
| Output rate | Variable | Constant |
| Excess requests | Rejected immediately | Queued until overflow |
| Added latency | None when tokens remain | Queue wait time |
| Best suited to | User-facing APIs | Protecting a fixed-rate dependency |
Algorithm Selection
| Algorithm | Memory per Key | Accuracy | When to Choose It |
|---|---|---|---|
| Fixed window | One counter | Low | Internal use where bursts are harmless |
| Sliding window log | One entry per request | Exact | Low-volume, high-sensitivity operations |
| Sliding window counter | Two counters | High | General-purpose API limiting |
| Token bucket | Token count and timestamp | High | When controlled bursts are desirable |
| Leaky bucket | Queue state | High | Protecting fixed-throughput backends |
Choosing the Limiting Key
The identity used for counting determines whether the limit protects anything. A poorly chosen key either fails to stop abuse or punishes innocent users.
| Key | Strength | Weakness |
|---|---|---|
| IP address | Works for unauthenticated traffic | Shared by offices and mobile carriers |
| User identifier | Precise attribution | Unavailable before authentication |
| API key | Clear ownership and tiering | Attacker may obtain many keys |
| Tenant identifier | Enforces multi-tenant fairness | One tenant user can exhaust the allowance |
| Session identifier | Granular per active session | Trivially reset by reconnecting |
| Device fingerprint | Survives address changes | Imprecise and privacy sensitive |
| Composite key | Layers several dimensions | Higher storage and complexity |
async function checkLayeredLimits(request) {
const checks = [
{ key: `ip:${request.sourceIp}`, limit: 600, windowMs: 60_000 },
{ key: `user:${request.userId}`, limit: 120, windowMs: 60_000 },
{ key: `tenant:${request.tenantId}`, limit: 5000, windowMs: 60_000 },
{ key: `endpoint:${request.userId}:${request.route}`, limit: 30, windowMs: 60_000 }
];
for (const check of checks) {
const result = await slidingWindowCounter(
check.key,
check.limit,
check.windowMs
);
if (!result.allowed) {
return {
allowed: false,
violatedScope: check.key.split(":")[0],
resetMs: result.resetMs
};
}
}
return { allowed: true };
}
Cost-Based Limiting
Treating every request as equally expensive is a poor approximation. A bulk export and a status check consume vastly different resources, yet a simple counter charges them the same.
| Operation | Relative Cost | Rationale |
|---|---|---|
| Read a single record | Low | Indexed lookup, often cached |
| Paged list query | Moderate | Scans and sorts a result set |
| Complex search | High | Fans out across index shards |
| Bulk export | Very high | Large scan and sustained transfer |
| Report generation | Very high | Aggregation over large datasets |
Distributed Enforcement
With many application instances, a purely local counter permits far more traffic than intended. The effective limit becomes the configured limit multiplied by the instance count.
| Approach | Accuracy | Latency Impact | Failure Behaviour |
|---|---|---|---|
| Local only | Poor with many instances | None | Unaffected |
| Centralized store | High | One network round trip | Store becomes critical |
| Local with sync | Approximate | Minimal | Degrades gracefully |
| Allocated budget | Good when balanced | Minimal | Uneven under skew |
| Sticky routing | High per key | None | Rebalancing loses state |
Atomicity Is Mandatory
Failure Policy
Fail Open
- Allow requests when the store is unreachable
- Preserves availability
- Removes protection exactly when it may be needed
- Usually paired with a local fallback limit
Fail Closed
- Reject requests when the check cannot run
- Maintains protection guarantees
- Converts a store outage into a full outage
- Appropriate for security-critical endpoints
Communicating Limits to Clients
A rejection that carries no information forces clients to guess, and guessing typically means retrying immediately, which worsens the overload.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 34
Retry-After: 34
{
"error": "rate_limit_exceeded",
"message": "Request limit exceeded for this endpoint.",
"scope": "user",
"limit": 120,
"windowSeconds": 60,
"retryAfterSeconds": 34,
"requestId": "req-cc71e4"
}
| Element | Purpose |
|---|---|
| Status 429 | Signals rate limiting specifically, not a generic error |
| Limit header | Lets clients self-pace before hitting the ceiling |
| Remaining header | Enables proactive backoff |
| Reset value | Indicates when capacity returns |
| Retry-After | Gives an unambiguous wait instruction |
| Scope field | Clarifies which limit was violated |
| Request identifier | Supports diagnosis without exposing internals |
Client-Side Backoff
Limits only work when clients respond sensibly. Synchronized retries produce a thundering herd at the moment the window resets.
async function callWithBackoff(operation, options) {
let attempt = 0;
while (attempt < options.maxAttempts) {
const response = await operation();
if (response.status !== 429) {
return response;
}
attempt += 1;
const serverHint = Number(
response.headers.get("Retry-After")
);
const baseDelayMs = Number.isFinite(serverHint) && serverHint > 0
? serverHint * 1000
: options.baseDelayMs * Math.pow(2, attempt - 1);
const jitterMs = Math.random() * baseDelayMs * 0.3;
await sleep(baseDelayMs + jitterMs);
}
throw new Error("Rate limit retries exhausted");
}
Abuse-Specific Limits
Some endpoints warrant far stricter treatment than general capacity limits, because the operation itself is the attack surface.
| Endpoint | Attack | Limiting Strategy |
|---|---|---|
| Login | Credential stuffing | Limit per account and per address, with lockout |
| Password reset | Account enumeration | Strict per-address limit, uniform responses |
| Registration | Bulk fake accounts | Address limits plus additional verification |
| One-time code send | Cost abuse and harassment | Per-recipient and per-sender limits |
| Search | Catalogue scraping | Cost-weighted limits and pagination depth caps |
| Export | Data exfiltration | Low limits with audit alerting |
Threats and Mitigations
| Threat | Description | Mitigation |
|---|---|---|
| Distributed sources | Attack spread across many addresses | Layer identity-based limits above address limits |
| Header spoofing | Forwarded-for header manipulated | Trust only proxy-appended values |
| Key rotation abuse | Fresh sessions or keys reset counters | Limit on durable identity and account age |
| Expensive endpoint targeting | Cheapest count, highest cost requests | Cost-weighted consumption |
| Race condition bypass | Concurrent requests read a stale counter | Atomic check-and-update |
| Limiter as bottleneck | Counter store becomes the constraint | Sharding, pipelining, and local pre-filters |
| Reset stampede | All clients resume simultaneously | Sliding windows and mandated jitter |
| Enumeration via responses | Different limits reveal valid accounts | Uniform responses and timing |
Monitoring
Signals Worth Tracking
- Rejection rate by endpoint and by scope
- Callers persistently at their ceiling
- Distribution of consumption across tenants
- Counter store latency and error rate
- Fail-open activations and their duration
- Retry rate following 429 responses
- Traffic shape immediately after window reset
- New identities appearing in bulk
- Cost-weighted consumption against request count
- Limit configuration changes
Common Design Mistakes
Weak Design
- Limiting solely by source address
- Counting every request as equally expensive
- Using local counters across many instances
- Performing read-then-write without atomicity
- Returning 429 with no retry guidance
- Applying one global limit to all endpoints
- Omitting jitter from client retries
- Hard-coding limits requiring redeployment
- Never monitoring rejection rates
Strong Design
- Layers address, user, and tenant limits
- Weights consumption by operation cost
- Shares state through an atomic store
- Evaluates check and update as one operation
- Returns limit, remaining, and reset headers
- Tunes limits per endpoint sensitivity
- Requires jittered client backoff
- Makes limits configurable at runtime
- Alerts on rejection anomalies
System Design Interview Discussion
| Question | What Your Answer Should Cover |
|---|---|
| Which algorithm and why? | Burst tolerance, memory, and accuracy trade-offs |
| What is the limiting key? | Layered identity dimensions and their weaknesses |
| How does it work across instances? | Shared atomic store versus local approximation |
| What if the store fails? | Fail-open and fail-closed reasoning per endpoint |
| How do clients learn the limit? | Status code, headers, and retry guidance |
| How are costly endpoints handled? | Cost-weighted token consumption |
| How is a distributed attack handled? | Identity-layer limits and upstream controls |
| How do you avoid a retry storm? | Sliding windows, Retry-After, and jitter |
Implementation Checklist
Production Checklist
- Select an algorithm matching your burst tolerance
- Layer limits across address, user, and tenant
- Weight consumption by operation cost
- Set stricter limits on authentication endpoints
- Share counters through an atomic store
- Evaluate check and update in one operation
- Define fail-open or fail-closed per endpoint
- Provide a conservative local fallback
- Return 429 with limit and reset headers
- Include Retry-After on every rejection
- Make limits configurable without deployment
- Support per-tier and per-customer overrides
- Require jittered backoff in client libraries
- Avoid lockouts keyed solely on the victim
- Keep responses uniform to prevent enumeration
- Monitor rejection rates and store health
- Load-test the limiter itself under peak traffic
Knowledge Check
What is the fixed window boundary problem?
A caller can send the full limit at the end of one window and again at the start of the next, transmitting twice the intended rate across a short span.
Why is token bucket commonly preferred?
It permits natural bursts up to the bucket capacity while bounding the sustained rate through the refill rate, which matches real client behaviour.
Why is address-only limiting insufficient?
Shared networks are penalized collectively while distributed attackers using many addresses evade the limit entirely.
Why must the check be atomic?
Separate read and write operations let concurrent requests observe the same stale count and all proceed, silently exceeding the limit.
Why add jitter to retries?
Without it, all blocked clients retry at the same moment, producing a synchronized spike when the window resets.
Summary
Rate limiting bounds how quickly a caller may consume a service, protecting capacity, enforcing fairness between tenants, and blunting abuse. It is distinct from quotas, throttling, and load shedding, which operate on different horizons.
Fixed windows are simple but permit double the limit at boundaries. Sliding window logs are exact but memory-hungry. Sliding window counters offer a practical balance, while token buckets permit controlled bursts and leaky buckets smooth output for fragile dependencies.
The limiting key matters as much as the algorithm. Address-only limits punish shared networks while missing distributed attacks, so layering address, user, and tenant dimensions is necessary. Cost weighting prevents callers from concentrating on the most expensive endpoints.
Across a fleet, counters must be shared and updated atomically, with a deliberate failure policy. Rejections should return 429 with limit, remaining, reset, and Retry-After, and clients must back off with jitter to avoid a synchronized retry storm.
Key Takeaway
Limit on identity, weight by cost, and enforce atomically. Choose an algorithm matching your burst tolerance, layer limits across several identity dimensions, share counters through an atomic store, decide explicitly how to behave when that store fails, and tell clients exactly when to retry.