Skip to main content
Rate Limitinglesson 4 of 4 · 2 min read

Telling Clients No, Usefully

The refusal shapes what happens next

How you refuse a request shapes what the client does next, and a badly communicated limit creates the exact retry storms your limiter exists to prevent.

Return the status code that means too many requests, not a generic error, because clients and SDKs key their retry behaviour off that code.

Include a header saying how many seconds to wait, which converts a guessing client into an obedient one.

Send the limit, the remaining count, and the reset time on every response, not only on refusals. The remaining count on successful responses is the load-bearing one, because it lets a well-built client slow down before it hits the wall.

Ask your clients for backoff with randomness in return. Wait a second, then two, then four, then eight, with jitter added so a thousand rejected clients do not all come back on the same tick.

Implement that in your own SDKs so your customers get correct behaviour without reading a specification. The jitter is the difference between a smooth recovery and synchronised waves of retries.

Two refinements

Add two product-level refinements that separate good APIs from adequate ones. Charge more for expensive operations, so a search costs ten tokens where a simple fetch costs one, being honest about what you are actually protecting.

And give large customers warning. Alert them at 80 percent of their quota, by dashboard or webhook, so the conversation about upgrading happens before their integration breaks on launch day rather than after.

the shape of it
ClientYour API429 + Retry-AfterBack off, jitterAccepted1. over the limit2. refuse clearly3. client obeys4. retries later
step 1 of 4
A refusal that says how long to wait turns a retry storm into an orderly queue.

Worked example

Chandra's team at a payments API notices a pattern in support tickets: customers hitting limits see failures with no explanation, retry immediately, and make their own problem worse; one customer's checkout integration loops hard enough to stay rate limited for 40 minutes. The fix ships in one sprint: 429s gain Retry-After, all responses gain the RateLimit trio, and the official SDKs get exponential backoff with jitter that honors Retry-After automatically. The public docs add a page showing the headers with worked examples. Over the next quarter, rate-limit support tickets drop from about 30 a month to 4, and the average duration a throttled key stays throttled falls from minutes to seconds, because clients now back off instead of hammering. Nothing about the limits changed, only the communication.

Rate Limiting: wrapping up

In the real world

  • 01Stripe has published its rate limiting design: token buckets in Redis guarding the API, plus separate concurrency limiters, with different policies for live-mode and test-mode traffic.
  • 02Cloudflare uses a sliding window counter across its edge network and has written up why: fixed windows allow boundary bursts, and the weighted two-window approximation tracks true rates closely on production traffic.
  • 03GitHub's REST API gives authenticated users 5,000 requests per hour and communicates state through rate limit headers, while its GraphQL API assigns per-query point costs based on query shape.
  • 04Shopify enforces a token bucket of 40 requests refilling at 2 per second for standard API clients and reports bucket usage in a response header apps use to self-throttle.
  • 05AWS's architecture blog formalized exponential backoff with jitter after showing that synchronized retries from many clients produce load spikes exactly when a recovering service can least afford them.

Questions people ask

What should I rate limit on: IP address, user, or API key?

Key on the identity closest to the actual consumer. For authenticated APIs that means the API key or user ID, since one NAT gateway or corporate proxy can put thousands of legitimate users behind a single IP. IP-based limits still matter as an outer layer for unauthenticated endpoints like login and signup, where there is no key yet. Serious setups layer both, plus a global ceiling protecting total capacity.

Which algorithm should I pick if I just need something reasonable?

Token bucket. It allows the short bursts real clients produce, caps the sustained rate, needs only two numbers per client, and is what most gateways and Stripe-scale APIs use by default. Reach for sliding window counters when window-boundary bursts specifically hurt you, and leaky bucket when a fragile downstream needs perfectly smoothed traffic.

Is a rate limiter enough to stop a DDoS attack?

No. Application-level limiting handles abusive individual clients and modest floods, but a distributed attack from tens of thousands of sources sends few requests per source and can saturate your bandwidth before your limiter runs. Volumetric DDoS defense happens upstream, at CDNs and scrubbing layers like Cloudflare or AWS Shield. The two protections complement each other; the limiter is the fine-grained inner layer.

Quick review

Token Bucket:
bucket holds up to N tokens, refills at R tokens/sec. Request costs 1 token. Allows short bursts
Leaky Bucket:
requests drip out at fixed rate regardless of input rate. Smooths traffic bursts into steady stream
Fixed Window:
count requests in fixed time windows (00:00 to 01:00). Boundary burst problem (2× requests at window edge)
Sliding Window Log:
track exact timestamps of recent requests. Accurate, memory-heavy (stores one entry per request)
Sliding Window Counter:
blend of fixed windows using weighted count. Accurate enough, memory-efficient
Distributed enforcement:
Redis INCR + EXPIRE per key. Lua script for atomic check-and-increment
Rate limit headers:
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Return 429 Too Many Requests
the trade-off

Redis round-trip adds ~1ms per request. Use in-process token bucket with async Redis sync for lowest overhead.

in the room

Every public-facing API. DDoS protection, fair-use SLA, preventing one noisy tenant from affecting others.