Rate limiting & resilience ยท How it works

9 min read
Senior15 min read
Rapid overview

How it works

Where limits live

  • At the edge (API gateway, CDN, load balancer): cheapest place to reject abuse and protect everything behind it.
  • Per service: protects a service from its own callers, including internal ones.
  • Per dependency (client-side): a service limits how hard it calls a fragile downstream or a paid third-party API.

Limits are keyed by what you want to be fair between: API key, user, tenant, IP (weak โ€” NAT and proxies share IPs), or endpoint cost class.

Q: Why is limiting by IP address alone a weak rate-limiting strategy?

A: Because IP addresses do not map to users. Many users share one address behind corporate NAT, carrier-grade NAT or a proxy, so a per-IP limit throttles innocent users together; meanwhile an attacker with a botnet or rotating cloud addresses gets a fresh limit per address. IP limits are useful as a coarse edge defence against floods from unauthenticated traffic, but fairness and abuse control should key on an authenticated identity โ€” API key, user or tenant.

The algorithms

AlgorithmHow it worksStrengthWeakness
Fixed windowCount requests per clock window (per minute)Simplest, one counterBurst of 2ร— at the window boundary
Sliding window logStore a timestamp per request, count those in the last windowExactMemory per request
Sliding window counterWeighted mix of current and previous window countsSmooth, two countersApproximate
Token bucketBucket of capacity B refills at rate r; each request takes a tokenAllows bursts up to B, average rTwo parameters to tune
Leaky bucketRequests queue and drain at a fixed ratePerfectly smooth outputAdds queueing delay, drops when full
Q: What is the boundary problem with fixed-window rate limiting?

A: A client can send the full quota at the very end of one window and again at the very start of the next, so within a few seconds it sends twice the intended rate while never exceeding the per-window count. With a limit of 100 per minute, 100 requests at 12:00:59 and 100 at 12:01:00 are both allowed. Sliding windows fix it by counting over the last 60 seconds from now rather than per clock minute, and a token bucket fixes it by bounding bursts to the bucket size.

Token bucket

stateDiagram-v2 [*] --> HasTokens HasTokens --> HasTokens: request arrives, take one token, allow HasTokens --> Empty: last token taken Empty --> Empty: request arrives, reject with 429 Empty --> HasTokens: refill tick adds tokens at rate r HasTokens --> Full: refill reaches capacity B Full --> HasTokens: request arrives, take one token

A token bucket stores two numbers per key: tokens and last refill time. On each request, add elapsed ร— r tokens (capped at B), then take one if available. It permits short bursts up to B while holding the long-run rate to r, which matches how real clients behave. Weighted requests take more than one token (an export costs 10).

Q: How does a token bucket allow bursts while still limiting the average rate?

A: The bucket holds up to B tokens and refills at r tokens per second. A client that has been quiet accumulates tokens up to B and can spend them all at once โ€” a burst of B requests โ€” but after that it can only proceed at the refill rate r. Over any long interval it cannot exceed B plus r times the interval, so the average is bounded by r. Setting B controls how bursty clients may be, and r controls sustained throughput.

Distributed rate limiting

With many gateway instances, each must see the same counter.

  • Central store: keep buckets in Redis and update them atomically with a Lua script or INCR plus expiry. Accurate, adds a network hop per request, and Redis becomes a dependency (decide whether to fail open or closed when it is down).
  • Local limits with a share: each of N instances enforces limit/N locally. No hop, but uneven load balancing makes it inaccurate.
  • Local plus periodic sync: instances count locally and reconcile with the central store every few hundred milliseconds. Near-accurate, cheap, slightly over-admits during bursts.
Q: How do you implement a rate limit shared by 20 API gateway instances?

A: Store the per-key state centrally, usually in Redis, and make the check-and-update atomic โ€” a Lua script that refills a token bucket and takes a token in one round trip, or a sliding window counter with INCR and expiry โ€” so concurrent instances cannot both take the last token. Keep the store close to the gateways, and decide explicitly what happens if it is unreachable: fail open (allow traffic, protecting availability) for general APIs, fail closed for expensive or abuse-prone endpoints. If the extra hop is too costly, have each instance count locally and sync with the store every few hundred milliseconds, accepting a small overshoot.

Telling clients what happened

Reject with 429 Too Many Requests, a Retry-After header, and ideally RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers so well-behaved clients can slow down before they hit the wall. Use 503 with Retry-After when the service itself is overloaded rather than the client over its quota.

Q: What is the difference between returning 429 and 503 when rejecting requests?

A: 429 means this client has exceeded its own allowance โ€” the problem is the caller's request rate, and other clients are unaffected. 503 means the service is temporarily unable to handle requests at all, regardless of who sent them, typically because it is overloaded or in maintenance. Both should carry Retry-After. The distinction matters for client behaviour and for monitoring: a spike of 429s is abuse or a misbehaving client, a spike of 503s is a capacity or health problem.

Cascading failures

flowchart LR U[Users] --> A[Service A] A --> B[Service B] B --> D[(Slow database)] D -. latency rises .-> B B -. threads blocked waiting .-> A A -. retries triple load .-> B A -. thread pool exhausted .-> U

A dependency slows down; callers hold threads and connections while they wait; their pools fill; they time out and retry, multiplying load on the struggling dependency; their own callers now see slowness and retry too. Every layer's retries multiply: three layers each retrying three times turns one failing request into 27 calls at the bottom.

Q: How do retries at several layers turn a small failure into an outage?

A: They multiply. If each of three layers retries a failed call three times, one user request can produce 3 ร— 3 ร— 3 = 27 attempts against the bottom dependency โ€” exactly when it is already struggling. The extra load deepens the slowdown, which causes more timeouts and more retries. Fix it by retrying at one layer only (usually closest to the user or the failing call, not both), using retry budgets that cap retries to a percentage of normal traffic, backing off with jitter, and only retrying idempotent and transient failures.

Timeouts and retry budgets

  • Every network call has a timeout, set from the dependency's p99 plus margin, and shorter than the caller's own deadline.
  • Propagate deadlines: pass the remaining time budget downstream so work abandoned by the user is abandoned everywhere.
  • Retry only transient, idempotent failures, with exponential backoff and jitter.
  • Retry budget: allow retries to add at most, say, 10% to normal request volume; beyond that, fail fast.
Q: Why should timeouts get shorter as you go deeper in a call chain?

A: Because the caller stops waiting at its own deadline, and any work still running below it after that is wasted. If the edge gives up after 2 seconds but a backend call has a 5-second timeout, the backend keeps a thread and a connection busy for 3 seconds serving nobody, reducing capacity exactly when the system is slow. Setting each hop's timeout inside the remaining budget โ€” ideally by propagating a deadline with the request โ€” frees resources as soon as the answer can no longer be used.

Circuit breakers

stateDiagram-v2 [*] --> Closed Closed --> Open: failure rate above threshold in window Open --> HalfOpen: cool-down timer expires HalfOpen --> Closed: trial requests succeed HalfOpen --> Open: trial request fails

A circuit breaker wraps calls to a dependency. Closed: calls pass and failures are counted. Open: calls fail immediately (or use a fallback) without touching the dependency, giving it room to recover and freeing the caller's threads. Half-open: after a cool-down, a few trial calls go through; success closes the breaker, failure reopens it.

Q: What problem does a circuit breaker solve that timeouts and retries do not?

A: Timeouts bound how long each call waits, but a caller hitting a dead dependency still waits the full timeout on every request and keeps sending it load; retries make that worse. A circuit breaker notices the failure rate is high and stops calling altogether for a cool-down period, so the caller fails in microseconds instead of seconds (keeping its threads free and its own latency low), the dependency gets breathing room to recover, and a half-open probe detects recovery automatically.

Bulkheads

Named after ship compartments: partition resources so one failure floods only its compartment. Separate thread pools or connection pools per dependency, separate worker pools for heavy and light endpoints, separate deployments for critical and non-critical traffic, and per-tenant concurrency limits. If the recommendations service hangs, only the recommendations pool is exhausted; checkout still has its threads.

Q: What is a bulkhead in system design? Give an example?

A: Isolating resources so that one failing part cannot consume the capacity others need. Example: a product page calls pricing, inventory and recommendations. With one shared pool of 200 HTTP connections, a hanging recommendations service can hold all 200 and take pricing down with it. With a separate pool per dependency โ€” say 100 for pricing, 60 for inventory, 40 for recommendations โ€” a recommendations hang exhausts only its 40, and the page renders without recommendations.

Backpressure, load shedding and graceful degradation

  • Backpressure: a slow consumer signals producers to slow down โ€” bounded queues that block or reject when full, TCP flow control, reactive streams. An unbounded queue is a memory leak with a delay.
  • Load shedding: when the server is at capacity, reject excess work early and cheaply (before parsing the body or calling dependencies), preferring to drop low-priority traffic first. Serving some users well beats serving everyone badly.
  • Graceful degradation: decide in advance which features are optional โ€” recommendations, personalisation, live counters โ€” and turn them off or serve cached versions under stress so the core path survives. Feature flags and fallbacks make this a switch, not a deploy.
Q: Why is an unbounded queue dangerous in front of an overloaded service?

A: It hides overload until it is catastrophic. Work keeps being accepted, the queue grows, every item waits longer, and by the time items are processed the users who sent them have given up โ€” so the service spends its capacity on requests nobody is waiting for, while memory grows until the process dies. A bounded queue with rejection (backpressure or load shedding) keeps latency predictable for the requests it accepts and tells callers immediately to back off or try elsewhere.

See also