TL;DR

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:

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.

๐Ÿง  Analogy A nightclub bouncer lets in at most a certain number of people per minute so the club never gets dangerously crowded, and refuses to let the same person keep going in and out every 10 seconds. A theme-park ride gives each visitor a limited number of "fast pass" tokens per day, which refill slowly โ€” use them all in a burst and you wait. Rate limiters are those bouncers and token machines for an API.

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).

๐Ÿ“ฑ Clientdebounce, backoff CDN / WAFper-IP, DDoS API Gatewayper-user, per-route Servicebusiness limits Databaseconn. pools Redis counters coarse, cheap โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ precise, domain-aware
Each layer catches different abuse: the edge stops floods cheaply, the gateway enforces per-user API limits, services enforce business rules.
LayerWhat it does wellLimitationsInstagram example
Client-sidePrevents accidental floods: debounce double-taps, exponential backoff on errors, honour Retry-AfterNever a security control โ€” attackers don't use your appApp 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 centerOnly sees IPs; no user identity; shared IPs (carrier NAT) cause false positivesBlock an IP sending 50k req/s
API gatewaySees every request with authenticated identity; one consistent place for per-user / per-app / per-route limitsDoesn'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-serviceDomain-aware limits; protects the service's own capacity regardless of callerEach team must implement it; requests already consumed gateway capacityGraph 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.

KeyGood forPitfalls
User IDAuthenticated actions: likes, follows, comments, postingAttackers create many accounts โ€” combine with device/IP limits and signup limits
IP addressUnauthenticated endpoints: login, signup, password reset; DDoSMobile carriers and offices put thousands of users behind one IP (CGNAT); IPv6 gives attackers huge address ranges (limit by /64 prefix)
Device IDCatches one phone farming many accountsCan be spoofed by emulators; reset on reinstall
API token / app IDThird-party developers and partner quotasOne app serves many users โ€” usually combine with per-user-per-app limits
Endpoint / actionDifferent budgets for expensive vs cheap operationsToo many rules become hard to reason about
Compositeuser:123:action:follow, ip:1.2.3.4:route:/login, app:77:user:123Memory 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
โš ๏ธ Numbers in this lesson are illustrative Instagram does not publish its exact limits (publishing them would help spammers tune their bots), and they vary by account age, reputation and behaviour. Treat all numbers here as assumptions for learning.

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

AlgorithmBurst handlingMemory per keyAccuracyBest for
Token bucketAllows controlled bursts up to B, then rate RO(1): tokens + timestampExact for its modelGeneral API limits; the default choice
Leaky bucketAbsorbs bursts in a queue, outputs smooth rateO(1) meter or O(B) queueExactShaping traffic to a fragile downstream; outbound jobs
Fixed windowUp to 2ร— limit across a window boundaryO(1): one counterLow at edgesCoarse limits, daily quotas, simplicity
Sliding window logNo edge bursts; strictO(limit): every timestampExactSmall, security-sensitive limits (login, OTP)
Sliding window counterEdge bursts mostly smoothedO(1): two countersApproximate, very closeHigh-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:

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:

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:

  1. Local fast path: each instance checks a local bucket in memory (nanoseconds). Obvious abusers are rejected without touching Redis.
  2. 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.
  3. 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.
  4. 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.
Load Balancer Gateway instance 1local buckets (in RAM) Gateway instance 2local buckets (in RAM) Gateway instance Nlocal buckets (in RAM) Redis clusterglobal countersLua scripts sync every ~100 ms or per request for strict rules
Hybrid limiting: each instance decides locally in nanoseconds and reconciles with global counters asynchronously.

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
  }
}

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.

๐Ÿ’ก Anti-abuse twist: don't always tell the truth For third-party developers, transparent headers are great. For spam bots, precise feedback helps them tune their speed to sit just below the limit. Anti-abuse systems sometimes return vague messages ("Action blocked โ€” try again later"), randomise thresholds, or shadow-limit (silently accept but don't apply/display the action) so bot operators can't easily detect the boundary.

7. Soft vs hard limits, and quotas

ConceptMeaningExample
Hard limitStrictly enforced; excess requests are rejected10 failed logins per 15 min per account โ†’ reject further attempts
Soft limitExceeding it triggers a gentler response: a warning, a CAPTCHA/challenge, extra logging, reduced priority, or a manual review โ€” not an outright blockAfter 150 follows in an hour, show "We limit how often you can do certain things" and require a challenge
QuotaA budget over a long period (day/month), often tied to a plan or billing; usually fixed window, reset at a set timeThird-party app: 200 calls/hour per user token; partner tier: 5M calls/day
Burst vs sustainedTwo-tier limits: short-term burst allowance plus a long-term ceiling20 likes/minute burst, 500 likes/day sustained
Adaptive / reputation-basedLimits adjusted by trust signalsNew 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 limitingLoad sheddingBackpressure
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?"
TriggerPer-key counter exceeds a configured limit โ€” even if the system is idleServer health signals: CPU, queue length, latency, concurrencyDownstream buffer/queue filling up
ActionReject that caller (429)Drop lowest-priority work for everyone (503), keep core features aliveSignal upstream to slow down or pause (blocking, credit-based flow control, TCP window)
ScopeFairness & abuseSurvival under overloadFlow control in pipelines/streams
Instagram exampleBot hits the hourly like capDuring a spike, skip prefetching Explore and ranking enrichment; keep feed and posting workingFeed 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

๐Ÿ“ธ In Instagram Instagram publicly tells users that it "limits how often you can do certain things" โ€” users who follow, like or comment too rapidly see an "Action Blocked" / "Try Again Later" message. These limits exist primarily to stop spam and automation, and they are not published. Third-party developers using Meta's Graph APIs get documented per-app and per-user call limits and receive rate-limit usage information in response headers. All specific numbers below are assumptions for learning.

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:

ActionExample limit (assumed)AlgorithmResponse on breach
Follow~200/hour, ~1,000/day; lower for new accountsSliding window counter per user + per deviceSoft: challenge; hard: "Action Blocked" for hours
Like~300โ€“500/hourToken bucket (allow bursts while scrolling) + daily cap429 / action block
Comment~60/hour; identical text repeated is limited much harderSliding window + content-similarity spam checkComment hidden or blocked
DM to non-followersSmall daily number of message requestsFixed-window daily quotaRequests queued / blocked
Login~5โ€“10 failed attempts per account per 15 min; per-IP limits tooSliding window log (small, exact)Challenge (CAPTCHA, verification code), then temporary lockout
Password reset / OTP SMS~3โ€“5 per hour per account and per phone numberSliding window logHard block โ€” SMS costs money and enables abuse
โš ๏ธ Login limits: lockouts can become a weapon If 10 failed attempts lock the account, an attacker can lock any victim out by deliberately failing 10 times. Better: limit per (account, IP/device) pair, trigger a challenge rather than a hard lock, and let logins from known trusted devices through. See Authentication.

9.2 A follow request, through every layer

๐Ÿ“ฑ Appdebounced tap
โ†’
Edge / WAFper-IP flood check
โ†’
API Gatewayuser: 100 req/s
โ†’
Follow/Graph Servicefollows/hour, device
โ†”
Rediscounters
  1. The app debounces repeated taps and backs off if it recently got a 429.
  2. The edge checks per-IP request rates (cheap, catches floods).
  3. The gateway authenticates the user and checks the general per-user API budget (token bucket in Redis, local hybrid).
  4. The Graph service checks the follow-specific rules (user, device, account age) via one Lua script evaluating all rules at once.
  5. If allowed, the follow is written and a follow.created event 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.
  6. 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

11. Real-world technologies

TechnologyNotesWhen to use
Redis (INCR, sorted sets, Lua)The standard shared counter store; atomic scripts; TTLsCustom distributed limits at any scale
redis-cell / GCRA librariesRedis module / libraries implementing GCRA in one commandToken-bucket semantics without writing Lua
NGINX limit_req / limit_connLeaky-bucket request limiting and concurrency limits per key (local to each NGINX)Simple per-IP limits at a reverse proxy
Envoy local + global rate limitLocal 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 / ApigeeBuilt-in rate limiting and quota plugins/usage plans (token bucket in AWS)Managed API products, partner quotas
Cloudflare / AWS WAF / AkamaiEdge rate limiting by IP, path, headers; bot managementVolumetric abuse before it reaches your data center
Guava RateLimiter, Bucket4j, resilience4jIn-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.

๐ŸŽฏ Key takeaways

Related lessons