Rate limiting looks simple: count requests, reject the extra ones. The strategy you pick decides whether a client can still send double the limit in one second, whether a legitimate user behind a shared IP gets blocked, and whether your limit holds when ten servers count at the same time. This guide covers the four decisions that matter (the algorithm, the key, the response and the storage), compares the common algorithms with a simulation, and shows what happens to naive implementations under concurrency. The numbers come from code I ran, including Laravel's own limiter classes against a real Redis server.
Pick the algorithm by the behavior you can live with
Every algorithm answers the same question, "may this request pass?", but they differ in how they treat bursts. I simulated four of them with a virtual clock, each set to 10 requests per 10 seconds, and ran three scenarios:
- A. Boundary burst: 10 requests just before a window ends and 10 just after, all within one second.
- B. Flood: a client sends 100 requests per second for 30 seconds.
- C. Idle then burst: a client is quiet for 60 seconds, then sends 30 requests at once.
| Algorithm | A: allowed within 1 second | B: allowed in 30 s / most in any 10 s | C: allowed instantly |
|---|---|---|---|
| Fixed window | 20 | 30 / 10 | 10 |
| Sliding window log | 10 | 30 / 10 | 10 |
| Sliding window counter | 10 | 28 / 10 | 10 |
| Token bucket (capacity 10, refill 1 per second) | 10 | 39 / 19 | 10 |
Fixed window
Count requests in fixed buckets (for example per minute). It is the simplest to build and the cheapest to store: one counter per key. The flaw is in column A: a client can spend the whole limit at the end of one window and the whole limit again at the start of the next, so it effectively gets twice the limit in a short span. That is acceptable for coarse protection and wrong for anything where the limit is a hard promise.
Sliding window log
Store a timestamp for every accepted request and count those inside the last N seconds. It is exact: no span of N seconds ever contains more than the limit. The price is memory, because each key holds up to limit timestamps. That is fine for 10 requests, and expensive for 10,000 per key across millions of keys.
Sliding window counter
Keep two counters, the current window and the previous one, and estimate the sliding total as previous x (share of the window still overlapping) + current. It needs constant memory like the fixed window, and removes the boundary problem. It is an approximation, so it can be slightly stricter or looser than the exact answer; in the flood scenario it allowed 28 requests where the exact log allowed 30.
Token bucket
A bucket holds up to capacity tokens and refills at a steady rate; each request takes one token. This is the most flexible model because it separates two numbers: the sustained rate and the allowed burst. Look at column B: with a capacity of 10 and a refill of 1 per second it allowed 19 requests in the busiest 10-second span, because a full bucket pays for a burst on top of the steady refill. That is by design. If your limit must never exceed N per window, use a sliding window; if you want to allow short bursts while holding a long-term average, use a token bucket. A leaky bucket used as a meter behaves the same way mathematically, so most APIs treat the two as one idea.
Concurrency limits are a different tool
A rate limit counts requests over time. A concurrency limit caps how many requests from one client are in flight at once. It protects slow, expensive endpoints (exports, reports, AI calls) where a client sending 5 requests per second could still pile up 50 running jobs. Use it next to a rate limit, not instead of one.
Decide who is being limited
The key decides who shares a budget. A good limit is usually a stack of several:
- Per API key or authenticated user. The fairest key, and the one you can attach plans and quotas to.
- Per IP. The only option before login, but many users can share one IP (offices, mobile carriers, NAT). Keep IP limits loose, and use them mainly to stop abuse of unauthenticated endpoints.
- Per route group or cost. A write that triggers emails, webhooks and push notifications costs far more than a read, so give it a tighter budget than the query suggests.
-
Per target account on sensitive actions. For logins, limit by the email being tried as well as by IP, so one attacker cannot spread guesses over many addresses. Laravel's documentation shows exactly this with
Limit::perMinute(3)->by($request->input('email')).
If your app sits behind a load balancer, make sure it trusts the proxy headers correctly, or every client will appear to come from the proxy's IP and share one budget.
What to send back when you block
Status code. Use 429 Too Many Requests. RFC 6585 defines it as the user having "sent too many requests in a given amount of time", says the response SHOULD include details explaining the condition, and says it MAY include a Retry-After header. The same RFC notes it does not define how the server identifies the user or counts requests, and that servers are not required to use 429, so this part is your design.
Retry-After. The header takes either a number of seconds to wait or an HTTP date, per MDN's documentation of the header. Seconds are simpler to produce and to parse:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
{"message": "Too many requests. Retry in 12 seconds."}
Limit headers. There is no finished standard yet. The IETF HTTPAPI working group has a draft that defines two fields, RateLimit-Policy and RateLimit; at the time of writing it is at revision 11 (23 May 2026) and still an Internet-Draft that expires on 24 November 2026. Many APIs, including Laravel, send the older de-facto X-RateLimit-Limit and X-RateLimit-Remaining headers instead. In Laravel's throttle middleware (framework source, tag v13.35.0) those two go on responses, and when a client is blocked the middleware adds Retry-After and X-RateLimit-Reset. Pick one convention, document it, and do not mix them.
The part most tutorials skip: counting under concurrency
Your limiter runs on many web workers at once. A counter that is read, compared and written in separate steps will let extra requests through, because several workers read the same old value. To measure it, I started 20 PHP processes at the same moment against one Redis server, each making 50 attempts on the same key (1,000 attempts total) with a limit of 100. A correct limiter allows exactly 100. Each pattern was run three times:
| Pattern | Allowed (limit 100) |
|---|---|
| Naive: read count, compare, write count + 1 | 579 to 943 |
Laravel tooManyAttempts() then hit() (what the throttle middleware does) |
113 to 119 |
Laravel Redis throttle: DurationLimiter::tooManyAttempts() then acquire()
|
104 to 108 |
RateLimiter::increment(), then compare the returned count |
100 every time |
Raw Redis INCR, then compare |
100 every time |
DurationLimiter::acquire(), using its own return value |
100 every time |
| Lua token bucket script (below) | 100 every time |
Two lessons. First, "check, then count" is racy, and the overshoot is bounded by how many requests are in flight at the same instant: each of the 20 workers can slip at most one extra request through, which is why the Laravel middleware stayed within 19 of the limit. For ordinary traffic against a per-user key that is invisible. Second, the fix is cheap: make the increment itself the decision. The Laravel documentation states that with the redis, memcached or database cache stores, increment is atomic, so each concurrent request receives a unique count:
use Illuminate\Support\Facades\RateLimiter;
if (RateLimiter::increment('send-sms:'.$user->id, 3600) > 5) {
abort(429, 'Too many messages.');
}
Use this form where the limit is a hard rule: SMS and email sends, password reset requests, anything that costs money. Keep the normal throttle middleware for general traffic shaping. If you write the counter yourself, remember that INCR followed by a separate EXPIRE is two steps: if the process dies between them the key never expires and the client is blocked forever. Use a Lua script, or set the key with an expiry first.
Laravel's built-in limiter is a fixed window
From the source: the first hit creates the counter and a timer that expire after the decay period, so the window starts at the client's first request, not on the clock boundary. The boundary burst still exists. I tested a limit of 5 per 2 seconds with the real RateLimiter on Redis: one request at 0.0 s starts the window, four more at 1.9 s use up the limit, a sixth is blocked, and after the window expires five more pass at 2.1 s. That is 9 requests allowed in about 0.2 seconds against a limit of 5 per 2 seconds. For most routes that is fine. For stricter protection, use a token bucket.
A token bucket you can copy
This Redis Lua script implements a token bucket. It runs atomically inside Redis and reads Redis's own clock with TIME, so servers with slightly different clocks cannot skew the refill. I ran it with the 20-process test above and it allowed exactly 100 of 1,000 attempts. On Redis 5.0 or newer a script may call TIME and then write, because scripts replicate their effects; older versions reject that combination unless you enable it explicitly.
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens per second
local now = redis.call('TIME')
local t = tonumber(now[1]) + tonumber(now[2]) / 1000000
local d = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(d[1])
local ts = tonumber(d[2])
if tokens == nil then tokens = capacity; ts = t end
tokens = math.min(capacity, tokens + (t - ts) * rate)
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
redis.call('HSET', KEYS[1], 'tokens', tostring(tokens), 'ts', tostring(t))
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
return {allowed, tostring(tokens)}
And a Laravel middleware that calls it, returning a correct Retry-After when the bucket is empty:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
class TokenBucket
{
private const SCRIPT = '...the Lua script above...';
public function handle(Request $request, Closure $next, int $capacity = 60, int $perMinute = 60)
{
$rate = $perMinute / 60;
$key = 'tb:'.($request->user()?->id ?? $request->ip());
[$allowed, $tokens] = Redis::eval(self::SCRIPT, 1, $key, $capacity, $rate);
if (! $allowed) {
return response()->json(['message' => 'Too many requests.'], 429)
->header('Retry-After', (int) ceil((1 - (float) $tokens) / $rate));
}
return $next($request);
}
}
Register an alias for it and apply it to a route group with Route::middleware('bucket:60,60'). The Lua script is the part I tested. The middleware wrapper follows the same eval call that Laravel's own DurationLimiter uses, but I did not run it inside a full Laravel application, so test it in yours.
Which strategy to use
| Situation | Use |
|---|---|
| General API protection, one app, modest traffic | Fixed window (Laravel throttle) keyed by user or IP |
| Public API with plans or quotas | Token bucket per API key, plus a daily or monthly quota |
| A hard limit that must not be exceeded (SMS, email, payments) | Atomic counter (increment, INCR or Lua), sliding window if the boundary matters |
| Login, password reset, OTP | Two limits: per account and per IP, both tight |
| Slow or expensive endpoints | Rate limit plus a concurrency limit |
| Millions of keys, limited memory | Sliding window counter or token bucket, not the log |
Common mistakes
- Counting in memory per server. With four servers behind a load balancer, an in-process counter gives each client four times the limit. Keep the counter in a shared store such as Redis.
- Using the same limit for reads and writes. Cost differs, so the budget should too.
-
Counting everything. Sometimes you only want to count failures. Laravel's
Limit::after()can count only responses you choose, for example 404s to slow enumeration. -
No
Retry-After. Clients then retry immediately, which makes the overload worse. Always tell them when to come back. - Keying on a header the client controls. If the key is a client-supplied value, an attacker simply changes it for each request. Key on authenticated identity or the connection's real IP.
- Deciding nothing about store failure. If Redis is down, does the API allow all requests or reject all of them? Choose deliberately and document it; either answer is defensible, but the default should not be an accident.
Frequently asked questions
Should I use a fixed window or a sliding window?
If you can tolerate a burst of up to twice the limit around a window boundary, a fixed window is cheap and simple. If the limit protects something that breaks under bursts, use a sliding window or a token bucket.
What is the difference between rate limiting and throttling?
The words are used loosely. Usually rate limiting rejects excess requests (429), while throttling slows them down or queues them. Laravel's throttle middleware rejects with 429.
Should I limit by IP or by user?
Both, in layers. Use the authenticated user or API key as the main key, and a looser IP limit for unauthenticated routes, keeping in mind that many real users can share one IP.
Originally published on DEV Talk.
Top comments (0)