Cruxly

Cruxly › System design › Scaling Building Blocks

Rate Limiting

protect the system, and the limit must be shared

Open the interactive lesson

A limit keeps one caller from consuming everyone's capacity. The algorithm choice is quick; the interesting parts are where the counter lives, what happens when the limiter itself is unavailable, and what you do with rejected traffic.

Decide what you are protecting first

Say which goal you're solving first.

GoalLimit
Abuse, scrapingPer account and per IP, aggressive, long window
Fair sharing among tenantsPer-tenant quotas by plan: isolation, not a global cap
Protecting a dependencyA global limit sized to its capacity, plus a circuit breaker. Per-user limits don't stop a thousand well-behaved users
Cost controlPer-account quotas over hours or days

Limits stack: per-IP at the edge, per-account in the gateway, a concurrency cap before each expensive dependency. Name the layers.

The four algorithms

Two of the four win in practice.

AlgorithmState per userWhat it gets wrong
Fixed window1 counter2× burst across the boundary: 100 at 10:00:59, 100 at 10:01:00
Sliding window log1 timestamp per requestExact, but 10,000 req/min = 10,000 timestamps per user
Sliding window counter2 countersA few percent error; almost always fine
Token bucket, the default2 numbers: refill rate, bucket sizeNothing, for user-facing traffic

Only token bucket says "sustained 10/s, but a burst of 50 is fine" directly; a fixed window can't express it. Leaky bucket smooths the output to a constant rate, for a downstream that wants perfectly even flow.

Where the counter lives

The real design question; the algorithm is the warm-up.

Counter locationVerdict
Instance memoryWrong. Twenty instances turn a limit of 100 into 2,000, and the multiplier changes with every autoscale. Only "works" if the load balancer pins each user to one instance; don't rely on that
Shared store (Redis), atomic incrementThe answer. Check-and-increment in one atomic round trip: read-then-write races under exactly the concurrency a limiter exists for. Costs a sub-millisecond hop on every request; say so
Local counters, reconciled every ~1 sEscape hatch at very high volume: bursts briefly overshoot, zero per-request cost. A trade you chose, not the default

:::key Store unreachable? You will be asked Fail open: an outage means unlimited traffic to everything behind it. Use for user-facing limits (availability wins). Fail closed: a blip takes the whole API down. Use when guarding an expensive downstream (cost and cascading failure win). Decide deliberately and say so. :::

What the client sees

Return 429 Too Many Requests with headers clients can act on:

HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30
Retry-After: 30
ProblemWhat happensFix
No Retry-AfterClients retry in a tight loop: a self-inflicted DoSAlways send it
Thundering retry (most candidates miss it)1,000 clients told Retry-After: 30 return at the same instant, rebuilding the spikeRetry-After + exponential backoff + jitter, built into your SDK: you can't trust arbitrary clients
Shaping (queueing) instead of rejectingTies up connections; under sustained overload it only moves the failureReject with a clear retry signal

Throttling: slowing callers instead of rejecting them

Everything so far rejects: over the limit, you get a 429. There is a second answer, delay: hold the request and release it at a pace the system can take. The two are different tools, and the choice is about who is waiting and for how long.

Reject (429)Delay (queue, then serve)
Caller seesAn immediate answer, then decidesA slow success, or a timeout
Server holdsNothingA connection, memory and a queue slot per waiter
FitsInteractive and user-facing traffic; anything with a short timeoutBatch jobs, webhooks, background syncs: callers who care about completion, not latency
Fails byClients that retry blindlyA queue longer than the caller's patience

Leaky-bucket shaping is the delay form: requests enter a bounded queue and drain at a constant rate, so a bursty caller becomes a steady one. The queue bound is the whole design. Worked arithmetic: drain at 100 requests per second with a 200-slot queue, and the last request waits two seconds. If callers time out at one second, every request past slot 100 is processed for nobody. Size the queue to rate x client timeout (here 100) and shed the rest at once. The AWS Builders' Library makes the same point: bound the queue by size and by age, and drop requests that are too old so the server can work on fresher ones. Stripe's rate-limiter post lets a caller burst above the steady rate for the same reason, because spikes such as flash sales are normal.

Throttling versus load shedding. Throttling is about whose traffic: this caller is over its share. Load shedding is about the server: whoever is calling, we are near overload, so some work must go. The AWS article frames the goal as keeping goodput, the work finished in time to be useful, flat as offered load rises, rather than collapsing. Stripe runs both layers: per-user request-rate and concurrency limiters, then a fleet-wide shedder that reserves capacity for critical methods (creating a charge) and rejects non-critical ones (listing charges) with a 503 first, and a last-resort worker-utilization shedder. Stripe's post ties 503 to its fleet-usage shedder; use 429 for "you are over your own limit" and 503 for "the service is overloaded", because keeping the two apart tells the client whether lowering its own rate will help.

Priorities and fairness. When something must go, pick it on purpose. The AWS article ranks the load balancer's health-check pings first, because a server that misses them is pulled from rotation and the fleet shrinks mid-overload, and it favours requests that finish an in-flight operation over ones that start new work. Then favour paid or critical traffic over batch. Google's overload chapter tags each request with a criticality (CRITICAL_PLUS down to SHEDDABLE) so the same rule applies across every service in a call chain. Fairness is the other axis: per-customer quotas can sum to more than capacity because customers rarely peak together, so shedding needs a rule for the moment they do.

What goes wrong: the retry storm. Rejection creates retries, and retries are more load at the worst moment. The SRE book (chapter 22) works the arithmetic: three retries, so four attempts, at each of three layers is 4 x 4 x 4 = 64 attempts on the database for one user action. The defences stack:

:::key Verified November 2026

:::

Further reading: Site Reliability Engineering, chapters 21 (Handling Overload) and 22 (Addressing Cascading Failures), and the AWS Builders' Library article "Using load shedding to avoid overload".

The traps

:::key Limiting the wrong identity: raise it unprompted Login has no authenticated account yet, so per-account limits can't apply. It needs two:

:::

TrapWhat it looks likeFix
Uniform costOne search costs 1,000× a profile fetch; both deduct one tokenWeight by cost, or limit per endpoint class
No burst allowanceA strict per-second cap breaks five parallel requests on page loadToken bucket
Silent limitingNo 429, so clients can't tell a limit from a failure and retry foreverAlways send an explicit 429
Internal callersYour own batch job trips abuse protection at 3amTheir own, much higher limit

How to say it in the interview

"The counter can't live in instance memory — with twenty instances, a limit of 100 becomes a limit of 2000. It goes in a shared store with atomic increment, and I need an answer for what happens when that store is unreachable."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons