- A rate limiter caps how many requests or actions a caller may perform in a time window (e.g. "200 likes per hour per account") and rejects the excess with HTTP 429 Too Many Requests.
- It protects against abuse and spam bots, keeps the system fair, controls cost, and shields downstream services from overload.
- Five classic algorithms: token bucket (allows bursts, most popular), leaky bucket (smooth output), fixed window (simple, edge bursts), sliding window log (exact, memory-hungry), sliding window counter (approximate, cheap โ a great default).
- At scale, counters live in Redis; use Lua scripts for atomicity, and a local + global hybrid to keep latency low.
- Rate limiting (per-caller fairness) is different from load shedding (dropping work when the server is overloaded) and backpressure (slowing producers down).
1. The problem: why does Instagram need rate limiting?
Every action on Instagram โ liking, following, commenting, logging in, searching โ is an API call that consumes CPU, database writes and network. Most users do a few hundred of these a day. But Instagram's openness invites people (and programs) who want to do millions:
- Spam and growth bots follow 5,000 accounts an hour hoping for follow-backs, then unfollow them; they mass-like and mass-comment ("๐ฅ DM us for collab!") to promote scams.
- Credential stuffing: attackers take username/password pairs leaked from other websites and try them against Instagram's login endpoint at thousands of attempts per second.
- Scrapers crawl profiles and posts to harvest personal data.
- Buggy clients: a mobile app release with a retry loop bug can make millions of phones hammer one endpoint simultaneously โ an accidental DDoS from your own users.
- Noisy neighbours: one third-party integration or one heavy user consuming a disproportionate share of capacity slows everyone else down.
Without limits, these callers degrade the experience for everybody, inflate the infrastructure bill, and can cascade into outages when a downstream database is overwhelmed. A rate limiter sets a ceiling per caller so that no one can consume more than their fair share.
1.1 Why, in one list
๐ก๏ธ Abuse & security
Slows brute-force logins, credential stuffing, scraping and enumeration attacks.
๐ค Spam bots
Caps follow/like/comment velocity so automation stops being profitable.
โ๏ธ Fairness
No single user, app or tenant can monopolise shared capacity.
๐ฐ Cost control
Bounds spend on expensive operations (ML inference, SMS for 2FA, third-party APIs).
๐๏ธ Protect downstream
Keeps databases and services below their safe throughput, preventing cascading failure.
๐ Commercial plans
Enforces quotas for partner/third-party API tiers (e.g. 200 calls/hour on a free tier).
2. Where to rate limit
Limits can be enforced at several layers โ and real systems use more than one (defence in depth).
| Layer | What it does well | Limitations | Instagram example |
|---|---|---|---|
| Client-side | Prevents accidental floods: debounce double-taps, exponential backoff on errors, honour Retry-After | Never a security control โ attackers don't use your app | App disables the Like button for 300 ms after a tap; backs off on 429 |
| Edge (CDN/WAF/LB) | Absorbs volumetric floods by IP before they reach the data center | Only sees IPs; no user identity; shared IPs (carrier NAT) cause false positives | Block an IP sending 50k req/s |
| API gateway | Sees every request with authenticated identity; one consistent place for per-user / per-app / per-route limits | Doesn't understand business semantics (is this a follow of a new account?) | 100 req/s per user overall; 10 login attempts/min per IP |
| Per-service | Domain-aware limits; protects the service's own capacity regardless of caller | Each team must implement it; requests already consumed gateway capacity | Graph service: max N new follows per hour; Like service: max likes per hour |
3. What to limit by: the rate-limit key
A rate limiter keeps a counter per key. Choosing the key decides who is being limited.
| Key | Good for | Pitfalls |
|---|---|---|
| User ID | Authenticated actions: likes, follows, comments, posting | Attackers create many accounts โ combine with device/IP limits and signup limits |
| IP address | Unauthenticated endpoints: login, signup, password reset; DDoS | Mobile carriers and offices put thousands of users behind one IP (CGNAT); IPv6 gives attackers huge address ranges (limit by /64 prefix) |
| Device ID | Catches one phone farming many accounts | Can be spoofed by emulators; reset on reinstall |
| API token / app ID | Third-party developers and partner quotas | One app serves many users โ usually combine with per-user-per-app limits |
| Endpoint / action | Different budgets for expensive vs cheap operations | Too many rules become hard to reason about |
| Composite | user:123:action:follow, ip:1.2.3.4:route:/login, app:77:user:123 | Memory grows with the number of distinct keys |
A request is typically checked against several rules at once, and is rejected if any rule is exceeded:
rules for POST /v1/users/{id}/follow:
- key: "user:{uid}:all" limit: 100 req / 1 s # general API budget
- key: "user:{uid}:follow" limit: 200 / 1 h # anti-spam action limit
- key: "user:{uid}:follow:day" limit: 1000 / 24 h
- key: "device:{did}:follow" limit: 300 / 1 h # catches multi-account farms
- key: "ip:{ip}:follow" limit: 2000 / 1 h # generous: shared IPs
4. The algorithms
All rate-limiting algorithms answer one question per request: "given what this key has done recently, allow or reject?" They differ in how they remember "recently", how they treat bursts, and how much memory they need.
4.1 Token bucket
Imagine a bucket that holds at most B tokens (the capacity or burst size). Tokens are added at a constant refill rate R per second, and the bucket never overflows. Each request takes one token (or a cost c for expensive requests). No token โ reject.
refill: R tokens/second (e.g. 2/s)
โ
โผ
โโโโโโโโโโโโโโโ
โ โ โ โ โ โ โ capacity B = 10 tokens
โ โ โ โ โ โ current = 9
โโโโโโโโฌโโโโโโโ
โ each request removes 1 token
โผ
request โโโถ token available? โโ yes โโโถ ALLOW (tokens -= 1)
โโ no โโโถ REJECT 429
Behaviour: a user who has been idle can burst up to B requests instantly, then is held to R per second on average. Over any interval of length t, at most B + Rยทt requests can pass.
The clever implementation trick: you don't need a timer adding tokens. Store just two numbers per key โ tokens and last_refill_time โ and compute the refill lazily on each request:
function allow(key, cost = 1):
now = current_time_seconds()
state = store.get(key) or { tokens: B, last: now }
elapsed = now - state.last
state.tokens = min(B, state.tokens + elapsed * R) # lazy refill
state.last = now
if state.tokens >= cost:
state.tokens -= cost
store.put(key, state)
return ALLOW
else:
store.put(key, state)
retry_after = (cost - state.tokens) / R # seconds until enough tokens
return REJECT(retry_after)
Worked example: B = 10, R = 1 token/s. A user idle for a minute sends 15 requests at once: the first 10 pass, the next 5 get 429 with Retry-After: 1. Two seconds later 2 tokens have refilled, so 2 more requests pass.
Pros: allows natural bursts (opening the app triggers a flurry of calls), O(1) memory (two numbers per key), easy to reason about, supports per-request cost. Cons: two parameters to tune; a full bucket allows a burst that might still hurt a fragile downstream. Used by AWS API Gateway, Stripe, NGINX-like limiters and most cloud APIs.
4.2 Leaky bucket
Picture a bucket with a small hole in the bottom. Requests pour into the bucket (a FIFO queue of capacity B) and leak out at a constant rate R to be processed. If the bucket is full, new requests overflow and are rejected.
bursty input โโโถ โโโโโโโโโโโโโ
โโโ โ โโโโ โ queue (B) โ โโโ full? โ REJECT (overflow)
โ โโโโโ โ
โโโโโโโฌโโโโโโ
โ constant drain: R requests/second
โผ
smooth output: โ โ โ โ โ โ โ โ โ โโโถ backend
queue = BoundedQueue(capacity = B)
on_request(req):
if queue.full(): return REJECT
queue.push(req) # will be processed later
worker loop (runs every 1/R seconds):
if not queue.empty():
process(queue.pop())
# "Leaky bucket as a meter" variant (no queue, just a counter):
function allow(key):
now = time()
s = store.get(key) or { level: 0, last: now }
s.level = max(0, s.level - (now - s.last) * R) # leak since last request
s.last = now
if s.level + 1 <= B:
s.level += 1; store.put(key, s); return ALLOW
store.put(key, s); return REJECT
Behaviour: output rate is perfectly smooth at R, no matter how bursty the input. The meter variant is mathematically equivalent to a token bucket (level = B โ tokens); the queue variant actually delays requests rather than rejecting them.
Pros: protects a downstream that needs a steady rate (e.g. a partner SMS gateway allowing 100 messages/s); O(B) or O(1) memory. Cons: queued requests wait, adding latency; a burst of old requests can starve new ones; not great for interactive APIs where users expect instant answers. Used in traffic shaping (network routers), NGINX's limit_req (leaky-bucket based, with optional burst queueing), and outbound job processing.
4.3 Fixed window counter
Divide time into fixed windows (e.g. each calendar minute: 12:00:00โ12:00:59). Keep one counter per key per window. Increment on each request; if it exceeds the limit, reject until the next window starts.
limit = 5 per minute window 12:00 window 12:01 |-----------------------------|-----------------------------| โ โ โ โ โ โ โ โ โ โ โ โ โ count: 1 2 3 4 5 6 7 1 2 3 4 5 6
function allow(key, limit, window_seconds):
window_id = floor(now() / window_seconds)
counter_key = key + ":" + window_id # e.g. "user:42:follow:28950012"
count = store.incr(counter_key) # atomic increment
if count == 1:
store.expire(counter_key, window_seconds)
return count <= limit
The boundary burst problem: with limit 100/minute, a client can send 100 requests at 12:00:59 and another 100 at 12:01:00 โ 200 requests in 2 seconds, double the intended rate, because they fall into different windows.
12:00:00 12:01:00 12:02:00
|---------- window A ---------|---------- window B ---------|
โโโโโโโโโ|โโโโโโโโโ
100 reqs | 100 reqs
โโโโโ 200 requests within ~2 s โโโโโ
Pros: trivial, one counter per key, works perfectly with Redis INCR + EXPIRE. Cons: up to 2ร bursts at window edges; all clients' windows reset at the same instant, causing synchronised "thundering herd" retries at the top of the minute.
4.4 Sliding window log
Keep a log of timestamps of each accepted request per key (e.g. a Redis sorted set). On each request, delete timestamps older than now โ window, count what remains, and allow if the count is below the limit.
limit = 3 per 60 s, request arrives at t=100 log before: [ 30, 45, 70, 95 ] drop < 100-60=40: [ 45, 70, 95 ] โ count 3 โฅ 3 โ REJECT at t=106: drop < 46: [ 70, 95 ] โ count 2 < 3 โ ALLOW, log = [70, 95, 106]
function allow(key, limit, window):
now = now_ms()
store.zremrangebyscore(key, 0, now - window) # evict old entries
if store.zcard(key) < limit:
store.zadd(key, score=now, member=now + ":" + random())
store.expire(key, window)
return ALLOW
return REJECT
Pros: exact โ at most limit requests in any rolling window; no boundary bursts. Cons: memory is O(limit) per key. For a limit of 1,000/hour and 10M active keys that's up to 10 billion timestamps (~8 bytes each plus overhead โ easily 100+ GB). Also more work per request. Use it when limits are small and accuracy matters (e.g. 5 password-reset emails per hour).
4.5 Sliding window counter
A clever approximation that combines fixed-window cheapness with sliding-window smoothness. Keep counters for the current and previous fixed windows, and estimate the count in the rolling window by weighting the previous window by how much of it still overlaps:
estimated = current_count + previous_count ร (1 โ elapsed_in_current / window)
limit = 100/min, request at 12:01:15 (25% into current window)
previous window (12:00) current window (12:01)
|------------------------------------|------------------------------------|
count = 84 count = 36
โโโโโโโโโโโโโ rolling 60 s window โโโโโโโโโโโโโถ
(75% of previous) (25% elapsed)
estimate = 36 + 84 ร 0.75 = 99 โ 99 < 100 โ ALLOW (count becomes 37)
function allow(key, limit, window):
now = now_seconds()
cur_id = floor(now / window)
elapsed = now - cur_id * window
cur = store.get(key + ":" + cur_id) or 0
prev = store.get(key + ":" + (cur_id - 1)) or 0
estimate = cur + prev * (window - elapsed) / window
if estimate >= limit: return REJECT
store.incr(key + ":" + cur_id)
store.expire(key + ":" + cur_id, 2 * window)
return ALLOW
It assumes requests in the previous window were evenly spread, so it's an approximation โ but in practice the error is small. Cloudflare has publicly reported that, measured on real traffic, only a tiny fraction (~0.003%) of requests were wrongly allowed or limited with this approach. Pros: two counters per key, smooths edge bursts, fast. Cons: approximate; slightly more complex than fixed window.
4.6 Comparison
| Algorithm | Burst handling | Memory per key | Accuracy | Best for |
|---|---|---|---|---|
| Token bucket | Allows controlled bursts up to B, then rate R | O(1): tokens + timestamp | Exact for its model | General API limits; the default choice |
| Leaky bucket | Absorbs bursts in a queue, outputs smooth rate | O(1) meter or O(B) queue | Exact | Shaping traffic to a fragile downstream; outbound jobs |
| Fixed window | Up to 2ร limit across a window boundary | O(1): one counter | Low at edges | Coarse limits, daily quotas, simplicity |
| Sliding window log | No edge bursts; strict | O(limit): every timestamp | Exact | Small, security-sensitive limits (login, OTP) |
| Sliding window counter | Edge bursts mostly smoothed | O(1): two counters | Approximate, very close | High-volume per-user limits at scale |
Two related ideas: GCRA (Generic Cell Rate Algorithm), a token-bucket variant that stores a single "theoretical arrival time" per key (used by libraries such as redis-cell and throttled); and concurrency limits, which cap the number of in-flight requests rather than requests per second (useful for long or expensive calls).
5. Distributed rate limiting with Redis
Instagram runs hundreds of gateway instances. If each kept its own counters, a user could get limit ร number_of_instances by having requests spread across them. The counters must be shared โ typically in a Redis cluster, because it's in-memory (sub-millisecond), supports atomic operations and key expiry, and shards horizontally. See Caching (Redis).
5.1 Fixed window with INCR + EXPIRE
key = "rl:user:42:like:" + floor(now/3600) # hourly window
MULTI
INCR key
EXPIRE key 3600 NX # set TTL only if none (Redis 7+); older: check INCR result == 1
EXEC
if result_of_INCR > 200 โ 429
INCR is atomic, so two concurrent requests can never both read 199 and write 200. The EXPIRE makes old windows disappear automatically, so memory stays bounded.
5.2 Race conditions: why "read, then write" breaks
Algorithms that read state, compute, then write (token bucket, sliding window log) have a classic race when two gateway instances handle the same user simultaneously:
Gateway A Redis (tokens=1) Gateway B
GET tokens โโโโโโโโโโโโโโโโโโถ 1
1 โโโโโโโโโโโโโโโโโโโโโโโโโโโโ GET tokens
1 โฅ 1 โ allow, SET tokens=0 โโถ 0
0 โโโโโโโโโโโโโโโโโโโโโโโโโโโโ 1 โฅ 1 โ allow, SET tokens=0
Result: 2 requests allowed with only 1 token โ
Fixes: make the whole read-modify-write atomic on the Redis server. Options are MULTI/EXEC with WATCH (optimistic locking, retries under contention), or โ the standard answer โ a Lua script. Redis executes a Lua script atomically: no other command runs in the middle. It also saves round trips.
5.3 Token bucket as a Redis Lua script
-- KEYS[1] = bucket key, ARGV = capacity, refill_per_sec, now_ms, cost
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local elapsed = math.max(0, now - ts) / 1000
tokens = math.min(capacity, tokens + elapsed * rate)
local allowed = 0
local retry_ms = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_ms = math.ceil((cost - tokens) / rate * 1000)
end
redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
-- expire idle buckets once they would be full again
redis.call("PEXPIRE", KEYS[1], math.ceil(capacity / rate * 1000) + 1000)
return { allowed, math.floor(tokens), retry_ms }
The gateway calls it with EVALSHA <sha> 1 rl:user:42:api 100 50 <now_ms> 1. Details that matter in production:
- Clock source: different gateway machines have slightly different clocks. Either pass a time from a well-synchronised clock (NTP) and tolerate small skew, or use Redis's own
TIMEcommand inside the script so one clock is authoritative. - Cluster keys: in Redis Cluster all keys a script touches must hash to the same slot; use hash tags like
rl:{user:42}:curandrl:{user:42}:prev. - Sharding: keys spread naturally across shards by user ID (see consistent hashing). Watch for hot keys โ e.g. a single per-IP key for a huge carrier NAT.
5.4 Latency and availability concerns
A Redis round trip within a data center is ~0.2โ1 ms. Fine for one check โ but a request might hit 3โ5 rules, at millions of requests per second. Mitigations:
- Evaluate all rules for a request in one Lua script or one pipelined batch โ one round trip.
- Co-locate Redis with gateways in the same zone/region; don't make cross-region calls for rate limiting.
- Decide fail-open vs fail-closed when Redis is down: fail open for ordinary traffic (an outage of the limiter shouldn't take down Instagram), fail closed (or fall back to strict local limits) for sensitive endpoints like login and OTP.
- Per-region counters: a user's requests normally stay in one region, so global (cross-region) exactness is rarely worth the latency. Accept that a determined attacker spreading across regions gets slightly more.
5.5 Local + global hybrid
To avoid a network hop on every request, each gateway instance keeps local in-memory token buckets and synchronises with the global store only periodically:
- Local fast path: each instance checks a local bucket in memory (nanoseconds). Obvious abusers are rejected without touching Redis.
- Batch sync: every ~100 msโ1 s, each instance pushes its local counts to Redis (
INCRBY) and reads back the global total for active keys. - Lease / quota allocation variant: an instance "borrows" a chunk of the user's budget (e.g. 10 tokens) from the global store and spends it locally; when exhausted, it asks for another chunk.
- Trade-off: between syncs, a user spread over many instances can slightly exceed the limit. For anti-abuse limits measured per hour, overshooting by a few requests is irrelevant; for strict limits (OTP attempts), go straight to the global store.
6. Telling the client: HTTP 429 and rate-limit headers
A well-behaved limiter tells clients what happened and when to try again, so good clients back off instead of retrying blindly.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 120
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1793098920
{
"error": {
"code": "RATE_LIMITED",
"message": "You're doing that too often. Please try again later.",
"retry_after_seconds": 120
}
}
429 Too Many Requestsโ the standard status for "you, specifically, are over your limit". (Contrast with503 Service Unavailableโ "the server is overloaded for everyone", used by load shedding.)Retry-Afterโ seconds to wait (or an HTTP date). Clients should honour it.X-RateLimit-Limit/-Remaining/-Resetโ the de-facto convention (GitHub, Twitter/X and many APIs) telling clients their budget on every response, so they can self-throttle. Reset is often a Unix timestamp. An IETF draft standardises unprefixedRateLimitandRateLimit-Policyheaders.
Client behaviour on 429: stop, wait Retry-After, then retry with exponential backoff plus jitter (random spread) so millions of clients don't all retry at the same instant. See Resilience patterns.
7. Soft vs hard limits, and quotas
| Concept | Meaning | Example |
|---|---|---|
| Hard limit | Strictly enforced; excess requests are rejected | 10 failed logins per 15 min per account โ reject further attempts |
| Soft limit | Exceeding it triggers a gentler response: a warning, a CAPTCHA/challenge, extra logging, reduced priority, or a manual review โ not an outright block | After 150 follows in an hour, show "We limit how often you can do certain things" and require a challenge |
| Quota | A budget over a long period (day/month), often tied to a plan or billing; usually fixed window, reset at a set time | Third-party app: 200 calls/hour per user token; partner tier: 5M calls/day |
| Burst vs sustained | Two-tier limits: short-term burst allowance plus a long-term ceiling | 20 likes/minute burst, 500 likes/day sustained |
| Adaptive / reputation-based | Limits adjusted by trust signals | New accounts get tighter follow limits than 5-year-old verified accounts |
8. Rate limiting vs load shedding vs backpressure
These three are often confused in interviews. They solve different problems:
| Rate limiting | Load shedding | Backpressure | |
|---|---|---|---|
| Question it answers | "Is this caller using more than their fair share?" | "Am I (the server) overloaded right now?" | "Is the consumer keeping up with the producer?" |
| Trigger | Per-key counter exceeds a configured limit โ even if the system is idle | Server health signals: CPU, queue length, latency, concurrency | Downstream buffer/queue filling up |
| Action | Reject that caller (429) | Drop lowest-priority work for everyone (503), keep core features alive | Signal upstream to slow down or pause (blocking, credit-based flow control, TCP window) |
| Scope | Fairness & abuse | Survival under overload | Flow control in pipelines/streams |
| Instagram example | Bot hits the hourly like cap | During a spike, skip prefetching Explore and ranking enrichment; keep feed and posting working | Feed fan-out workers fall behind; Kafka consumer lag grows and producers/consumers adjust; gRPC streams use flow-control windows |
A healthy system uses all three: rate limits stop individual abusers, load shedding protects the whole service from global surges, and backpressure keeps async pipelines from drowning. See Resilience patterns.
9. How Instagram uses rate limiting
9.1 Action limits against spam bots
Assume a real human rarely follows more than ~50 accounts in an hour, while a growth bot wants thousands. Layered limits in the Follow/Graph service and Like & Comment service:
| Action | Example limit (assumed) | Algorithm | Response on breach |
|---|---|---|---|
| Follow | ~200/hour, ~1,000/day; lower for new accounts | Sliding window counter per user + per device | Soft: challenge; hard: "Action Blocked" for hours |
| Like | ~300โ500/hour | Token bucket (allow bursts while scrolling) + daily cap | 429 / action block |
| Comment | ~60/hour; identical text repeated is limited much harder | Sliding window + content-similarity spam check | Comment hidden or blocked |
| DM to non-followers | Small daily number of message requests | Fixed-window daily quota | Requests queued / blocked |
| Login | ~5โ10 failed attempts per account per 15 min; per-IP limits too | Sliding window log (small, exact) | Challenge (CAPTCHA, verification code), then temporary lockout |
| Password reset / OTP SMS | ~3โ5 per hour per account and per phone number | Sliding window log | Hard block โ SMS costs money and enables abuse |
9.2 A follow request, through every layer
- The app debounces repeated taps and backs off if it recently got a 429.
- The edge checks per-IP request rates (cheap, catches floods).
- The gateway authenticates the user and checks the general per-user API budget (token bucket in Redis, local hybrid).
- The Graph service checks the follow-specific rules (user, device, account age) via one Lua script evaluating all rules at once.
- If allowed, the follow is written and a
follow.createdevent goes to Kafka. If a soft limit is hit, the response asks the app to show a challenge; if a hard limit, a 429 with a friendly message. - Breach events are logged to the anti-abuse pipeline (ML models), which can tighten limits on suspicious accounts.
9.3 Rough capacity numbers
Assume 1.5M API req/s at peak, each checked against ~3 rules โ 4.5M rule checks/s.
With one Lua script per request โ 1.5M Redis script calls/s.
If one Redis shard handles ~100k script calls/s โ ~15 shards (+ replicas), or fewer with
a local hybrid that syncs every 100 ms.
Memory (sliding window counter, 2 counters ร ~50 bytes incl. key overhead):
100M active users ร 5 rules ร 100 B โ 50 GB โ fits in a modest Redis cluster.
Sliding window LOG for a 500/hour rule instead: 100M ร 500 ร ~16 B โ 800 GB โ why we avoid it at scale.
9.4 Third-party API limits
Partner and developer apps that use Meta's Graph APIs (e.g. to schedule posts for businesses) are limited by a combination of app-level limits (scaled by the app's number of users), per-user-token limits, and per-endpoint cost. Usage is reported back in response headers, and apps exceeding limits are throttled. This is a classic quota system: a long window, tied to the app ID and token, enforced at the gateway.
10. Trade-offs & common pitfalls
โ Benefits
- Blocks spam, scraping, brute force and credential stuffing
- Fairness between users, apps and tenants
- Protects databases and services from overload and runaway clients
- Predictable cost for expensive operations (SMS, ML)
- Enables commercial quotas and plans
โ ๏ธ Costs
- Adds latency (a Redis hop) and a new dependency on the request path
- False positives frustrate legitimate power users and shared-IP users
- Distributed accuracy vs latency trade-off
- Tuning many limits is ongoing work; attackers adapt
- Memory for counters at high cardinality
Common pitfalls
- Per-instance counters only โ the effective limit becomes limit ร instances, and changes when you autoscale.
- Non-atomic read-modify-write โ race conditions allow overshoot; use
INCRor Lua. - Limiting only by IP โ punishes everyone behind a carrier NAT or university network while attackers rotate through residential proxies.
- Fixed windows aligned for everyone โ all clients' budgets reset at :00 and stampede; add jitter or use sliding windows/token buckets.
- Clients that retry immediately on 429 โ amplifies load; require backoff with jitter and honour
Retry-After. - Failing closed on limiter outage for all traffic โ the limiter becomes a single point of failure for the whole product.
- Rate limiting in place of capacity planning โ if legitimate users routinely hit limits, you need more capacity or better limits, not more 429s.
- Counting rejected requests toward the limit forever โ a client that keeps retrying never recovers; decide deliberately whether rejections consume budget.
11. Real-world technologies
| Technology | Notes | When to use |
|---|---|---|
| Redis (INCR, sorted sets, Lua) | The standard shared counter store; atomic scripts; TTLs | Custom distributed limits at any scale |
| redis-cell / GCRA libraries | Redis module / libraries implementing GCRA in one command | Token-bucket semantics without writing Lua |
NGINX limit_req / limit_conn | Leaky-bucket request limiting and concurrency limits per key (local to each NGINX) | Simple per-IP limits at a reverse proxy |
| Envoy local + global rate limit | Local token buckets per proxy plus a gRPC global rate-limit service (backed by Redis) | Gateways and service meshes needing the hybrid model |
| Kong / AWS API Gateway / Apigee | Built-in rate limiting and quota plugins/usage plans (token bucket in AWS) | Managed API products, partner quotas |
| Cloudflare / AWS WAF / Akamai | Edge rate limiting by IP, path, headers; bot management | Volumetric abuse before it reaches your data center |
| Guava RateLimiter, Bucket4j, resilience4j | In-process token-bucket libraries (JVM) | Local limits inside a service, client-side throttling |
12. Interview corner
Design a rate limiter for Instagram's API.
Clarify requirements: limit per user/IP/app per route, configurable rules, low latency (<1 ms overhead), highly available, distributed across hundreds of gateway instances, accurate enough (small overshoot OK except for login/OTP). Design: a rules config (key template, limit, window, algorithm, soft/hard) pushed to gateways; gateway middleware builds keys from the authenticated identity and route; counters in a sharded Redis cluster in each region; one Lua script per request evaluating all rules atomically (token bucket or sliding window counter); local in-memory buckets for a fast path with periodic sync; 429 with Retry-After and X-RateLimit-* headers; fail open with local fallback if Redis is down (fail closed for login). Domain-aware limits (follows/hour) in the owning services using the same library. Emit metrics on rejections per rule to tune limits and feed anti-abuse ML.
Token bucket vs leaky bucket โ when would you use each?
Token bucket allows bursts up to the capacity, then enforces the average rate; it rejects excess immediately โ ideal for interactive APIs where natural bursts (opening the app) are fine. Leaky bucket (queue form) smooths traffic to a constant output rate, delaying rather than rejecting bursts โ ideal when protecting a downstream that needs steady input, like an SMS provider or a partner API with strict QPS. As meters, they're mathematically equivalent.
What's wrong with a fixed window counter, and how do you fix it?
Boundary bursts: a client can use the full limit at the end of one window and again at the start of the next โ up to 2ร the limit within a short span. Also synchronised resets cause stampedes. Fix with a sliding window log (exact but memory-heavy) or a sliding window counter (weights the previous window's count by its overlap โ two counters, very close to exact), or use a token bucket.
How do you make distributed rate limiting correct under concurrency?
Make the check-and-update atomic at the store: Redis INCR for simple counters, or a Lua script for token bucket/sliding window logic (Redis runs scripts atomically). Use hash tags so all keys for one script live on one cluster slot. Use a single time source (Redis TIME) or tolerate small NTP skew. Avoid client-side read-then-write without WATCH/transactions.
Redis goes down. What happens to your rate limiter?
Decide per rule. General API limits fail open โ fall back to local in-memory buckets per instance (with limit รท number_of_instances as a rough per-instance budget), so the product stays up and gross abuse is still capped. Security-critical limits (login, OTP, password reset) fail closed or degrade to strict local limits and challenges. Alert immediately; Redis should be replicated with automatic failover to make this rare.
How is rate limiting different from load shedding and backpressure?
Rate limiting enforces per-caller fairness against configured limits, regardless of system load, returning 429 to that caller. Load shedding is the server protecting itself when overloaded, dropping lowest-priority work for everyone (503), based on health signals like queue depth or latency. Backpressure is flow control in a pipeline: a slow consumer signals producers to slow down (blocking queues, credit windows, Kafka consumer lag), rather than dropping work.
How would you stop spam bots from mass-following without hurting real users?
Layered, reputation-aware limits: per-user follows/hour and /day (sliding window counter), per-device and per-IP-prefix limits to catch account farms, tighter limits for new or unverified accounts. Use soft limits first (challenge, CAPTCHA, phone verification) before hard blocks. Combine with ML signals (follow/unfollow churn, identical timing patterns) and avoid exposing exact thresholds. Measure false-positive rates via appeals and power-user metrics.
13. Check your understanding
Token bucket with capacity 20 and refill 5 tokens/s. The bucket is full. A client sends 50 requests instantly, then 10 requests 2 seconds later. How many succeed?
First burst: 20 succeed (bucket empties), 30 rejected. After 2 s, 2 ร 5 = 10 tokens refilled โ all 10 of the second batch succeed. Total 30 succeed.
Fixed window, limit 100/min. What's the maximum number of requests a client can get accepted in any 60-second span?
Up to 200: 100 at the very end of one window and 100 at the very start of the next. That's the boundary-burst flaw.
Sliding window counter, limit 50/min. Previous minute had 40 requests; current minute has 20 so far; we're 30 s into the current minute. Is the next request allowed?
Estimate = 20 + 40 ร (1 โ 30/60) = 20 + 20 = 40 < 50 โ allowed.
Each of 200 gateway instances enforces "10 login attempts/min per IP" with in-memory counters only. What's the real limit an attacker can get?
Up to 200 ร 10 = 2,000 attempts/min, if the load balancer spreads their requests across instances โ and it changes whenever you autoscale. Login limits need a shared (global) store.
Why might you choose a sliding window log for OTP/SMS sending but not for a "1,000 API requests/hour" limit?
The OTP limit is tiny (e.g. 5/hour) so storing every timestamp costs almost nothing and you get exact enforcement on a security/cost-sensitive action. For 1,000/hour across 100M users, storing up to 1,000 timestamps per user is hundreds of GB โ a sliding window counter or token bucket gives nearly the same result with two numbers.
Which status code and headers should you return when a user exceeds a limit, and what should a well-behaved client do?
429 Too Many Requests with Retry-After and ideally X-RateLimit-Limit, -Remaining, -Reset. The client should stop, wait at least Retry-After, then retry with exponential backoff and jitter โ not hammer immediately.
During a viral event, Feed service latency spikes and its queue fills โ but no individual user is over their limit. Which mechanism should act?
Load shedding (and possibly backpressure), not rate limiting. The server should drop or degrade low-priority work (prefetch, non-essential ranking features) and return 503s for what it can't handle, protecting core functionality. Rate limits are per-caller and wouldn't trigger here.
- Rate limiting protects against abuse, spam and overload and enforces fairness and quotas.
- Pick keys carefully โ user, IP, device, app token, endpoint โ and combine several rules per request.
- Token bucket (bursts OK) and sliding window counter (cheap, accurate enough) are the workhorses; sliding log for small strict limits.
- Distributed limits need shared atomic counters (Redis INCR / Lua) and a local + global hybrid for low latency.
- Return 429 + Retry-After; distinguish rate limiting from load shedding and backpressure.
Related lessons
- API Gateway โ where most rate limits are enforced
- Caching (Redis & Memcached) โ the in-memory store that holds the counters
- Authentication & Authorization โ login attempt limits and identity used as rate-limit keys
- Resilience patterns โ backoff, jitter, load shedding and circuit breakers
- Security & Privacy โ DDoS protection, bots and abuse defences
- Social Graph & Follow Service โ where follow limits are applied
- Likes, Comments & Counters โ action limits on likes and comments