
How to Implement Rate Limiting for an API: In the Order That Actually Matters
Mahmud Hasan
October 21, 2026
The request that takes your API down rarely comes from a hacker. It comes from a legitimate customer with a retry loop, or a scraper that found your endpoint. Rate limiting is how you stay up — but most tutorials teach the happy path and skip the ways production limiters fail. This is the order that works: pick the algorithm for your bottleneck, identify clients honestly, tier by endpoint cost, return useful 429s, and plan for the limiter's own failure.
First, pick the algorithm for your bottleneck
Four serious algorithms for four different bottlenecks — pick the one that matches yours.
Fixed window is the simplest: one counter per client per window, reset at the window's end. Cheap and fine for internal APIs — with a known leak at the window edge: 100 requests at 12:00:59 plus 100 at 12:01:00 is 200 requests in two seconds, fully "compliant."
Sliding window log records every request timestamp and counts what falls inside the trailing window. Perfect accuracy, no edge leak — at the price of one stored entry per request per client. Reserve it for logins, MFA codes, password resets.
Token bucket holds up to N tokens, refilled at a fixed rate; each request costs a token, expensive endpoints cost ten. It absorbs bursts up to the bucket's capacity while enforcing the average rate — the most deployed algorithm in production. AWS API Gateway throttles with token buckets (10,000 requests/second steady-state plus a 5,000-request burst); Stripe's engineering writeup describes a per-user token bucket with burst allowance above the cap for flash sales.
Leaky bucket is the mirror image: requests queue and drain at a constant rate, so output is perfectly smooth. Choose it when your downstream can't absorb a burst — payment rails, legacy mainframes, anything where "eventually, in order" beats "fast."
Choose by what sits behind your API: if it can't absorb a burst, use a leaky bucket; if you're rate-limiting logins, use a sliding window log; otherwise use a token bucket. Fixed windows are for internal tools, not the public internet.
Key the limiter on the right identity
The algorithm is the part everyone writes about. The identity is the part that breaks.
Key by user ID when authenticated, IP when not. In Laravel's named limiters:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
Two traps in that last line. First, behind a load balancer or CDN, $request->ip() may resolve to the proxy's address — one shared budget for everyone. If a CDN terminates the connection, key on its signed header (Cloudflare's CF-Connecting-IP). Second, the naive fix is trusting X-Forwarded-For, and that header is client-controlled: one curl flag hands an attacker a fresh budget per request. Only trust forwarded headers from proxies you control — in Laravel, configure trusted proxies and ignore the header on direct connections.
And the mistake that silently starves paying users: one global bucket for everything. Without ->by(), one noisy scraper throttles your whole user base. Segment per user, and give anonymous traffic a smaller bucket than authenticated traffic. (On IPv6, group by subnet rather than keying on the full /128 — one user can rotate through an enormous address space; Traefik documents an ipv6Subnet option for exactly this.)
Tier by endpoint cost, not just by user
Sixty requests per minute is reasonable for fetching a profile; catastrophic for an endpoint that sends an SMS. Price rate limits in the currency of your infrastructure: expensive endpoints get stricter budgets, or cost more tokens in the same bucket.
Stripe documents it explicitly: a global 100 requests/second in live mode, 25 per individual endpoint, plus resource-specific caps. When you hit one, Stripe's 429 tells you which tier fired via the Stripe-Rate-Limited-Reason header. Copy that: the error names the rule that fired.
Five hundred reads a minute, three exports a minute.
The cheapest version is multiple limits per route — Laravel supports it out of the box:
RateLimiter::for('reports', function (Request $request) {
return [
Limit::perMinute(500)->by('minute:'.$request->user()->id),
Limit::perMinute(3)->by('exports:'.$request->user()->id),
];
});
Five hundred reads a minute, three exports a minute — price the limit in the cost of the endpoint.
Return a 429 that helps instead of hurting
A rate limiter that returns a bare 429 with no context is a locked door with no sign — the client retries immediately, in a loop, making your load problem worse. Four headers and a paragraph of documentation fix it.
Status code is 429 Too Many Requests (RFC 6585). Pair it with Retry-After, in seconds: Retry-After: 12. Subtlety from Cloudflare's feedback to the IETF: under token or leaky bucket schemes, the next request may be permitted before the window fully resets — so don't blindly echo the window reset.
For limit metadata, pick one of two conventions. The de-facto standard is X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset, used by GitHub and Laravel's throttle middleware. The newer convention is the IETF's RateLimit-Policy / RateLimit fields — but as of October 2026 it's still a draft, not an RFC. What breaks developers is mixing conventions or shipping none. Document yours with an example 429 response.
The four ways production limiters actually die
Run your implementation through this list before you ship it.
1. Retry storms. The clients you rate-limited retry immediately, all at once, synchronized — the thundering herd — and one 429 wave becomes a self-inflicted DDoS. Publish Retry-After, but more importantly make your clients behave: exponential backoff with jitter, which Stripe's docs recommend for exactly this reason.
2. Redis is down and nobody decided what happens next. Your limiter lives in Redis, and Redis will eventually be unreachable. Fail open for checkout: blocking all purchases over a cache hiccup is a business disaster. Fail closed for login and OTP endpoints: an attacker would love a password-guessing window with no limiter. Decide per route and test it by killing Redis in staging.
3. You forgot concurrency exists. Rate limits count requests per window, not simultaneous requests. Stripe maintains a separate global concurrency limit — 50 in-progress requests by default in live mode — and its 429s distinguish global-concurrency from global-rate. If your endpoints are slow, cap concurrency too.
4. Your check-and-record isn't atomic. A fixed-window check that INCRs, reads the count, then decides in application code races when two gateway nodes hit the last slot at the same instant. Run the whole thing as a Lua script — Redis executes Lua atomically — or document that your limit is approximate.
What to do on Monday
- Wire up a limiter you don't have to babysit. Laravel named limiter +
throttle:middleware,express-rate-limit, or Flask-Limiter — backed by Redis in production. - Key honestly. User ID when authenticated, IP when not, from a proxy chain you control. IPv6 goes in subnet buckets.
- Tier by cost. Generous for reads, strict for login, signup, exports, webhooks. Let the 429 name the tier that fired.
- Ship useful 429s.
429+Retry-Afterin seconds + one consistent header set, documented. - Test the failure modes. Kill Redis in staging; confirm checkout stays up while login stays guarded. Hammer staging and watch whether clients back off or stampede.
References
- Laravel 12.x docs — rate limiting (named limiters, Redis throttle, response-based limits): https://laravel.com/index.php/framework/docs/12.x/routing
- Stripe docs — rate limits (100 rps live, 25 rps per endpoint, backoff guidance): https://docs.stripe.com/rate-limits
- Stripe blog — Scaling your API with rate limiters: https://stripe.com/en-fi/blog/rate-limiters
- GitHub docs — rate limits for the REST API: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
- AWS docs — API Gateway request throttling (token bucket): https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-request-throttling.html
- Cloudflare docs — WAF rate limiting rules: https://developers.cloudflare.com/waf/account/rate-limiting-rulesets/create-api/
- IETF draft — RateLimit header fields for HTTP (
draft-ietf-httpapi-ratelimit-headers): https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ - SharpByte — designing a rate limiter: https://www.sharpbyte.dev/system-design/design-rate-limiter
- CodingPancake — distributed rate limiting at scale: https://www.codingpancake.com/2026/09/distributed-rate-limiting-at-scale.html
- Traefik docs — rate limit middleware (IPv6 subnet grouping): https://doc.traefik.io/traefik-hub/api-gateway/reference/routing/http/middlewares/ref-rate-limit
Comments
More in Web Development

Is the AI Bubble About to Burst? The Honest 2026 Answer
The IMF just put a number on the AI-bubble debate: an investment bust could shave roughly a point off global GDP by 2028. This week gave both sides fresh ammunition — here is the honest middle.
Read more
The Leap Second Is Dead: Why 69 Countries Voted to Abolish It
On October 15, 69 countries voted to abolish the leap second, effective May 2027. The real reason isn't the crashes of 2012 and 2017 — it's the negative leap second, a second that goes backward, that no computer has ever had to survive.
Read more
Workday Bought C1.ai to Give AI Agents a Login. Here's What Agentic Identity Management Actually Is
Workday just agreed to buy C1.ai, an identity platform for AI agents. Here is what agentic identity management actually does — and why your agents logging in as you is the part that should worry you.
Read more