HTTP & container
Why a limit of 60 a minute let 119 through
The mistake
throttle:60,1 reads like a promise: no more than 60 requests in any 60
seconds. People picture a sliding count that looks back one minute from every
request and refuses the 61st.
Laravel does not count like that. The limit applies to a window: a block of 60 seconds that starts with the first request. When the window ends, its count is thrown away. The next request starts a new window with a full budget. Two windows side by side are two separate budgets. A caller who fills the end of one window and the start of the next gets 119 requests through in two seconds. The limiter’s counter never goes above 60.
The machine
The windows, the counters and every header run on the tested reducer, checked against the real ThrottleRequests middleware on Laravel 13.
Drive it
The panel opens on a steady rate: 20 requests every 20 seconds under
throttle:60,1. All of them share one key. The big number in the middle counts the
requests that got through in the last 60 seconds. It reads 60. That is the
limit.
- Press “Back to 12:00:00”, then Send 1, Wait 59 s, Send 60, Wait 1 s and
Send 60. The first request opens a window. At 12:00:59 the next 60
arrive: 59 of them fill the window, and one is refused. At 12:01:00 the
window ends. The refused request comes back at 12:01:00, as
Retry-Aftertold it to. It opens a new window, and 59 more join it. Both counters read 60 of 60. The big number reads 119. - Press “Back to 12:00:00”, then Send 60, Wait 20 s, Send 20, Wait 20 s,
Send 20 and “Obey Retry-After”. The first group is refused at 12:00:20
with
Retry-After: 40. The second group is refused at 12:00:40 withRetry-After: 20. Both headers point at 12:01:00. When you obey them, all 40 arrive in the same second. - Press Reset, press Send 60 twice, then press “Obey Retry-After” twice. 80 callers are refused. All of them come back at 12:02:00, and 60 get through. The other 20 are refused again. They come back together at 12:03:00.
The mechanism
For each request, the throttle middleware first checks all the limits on the
route. Only then does it count the request against each limit:
// ThrottleRequests::handleRequest(), simplified
foreach ($limits as $limit) {
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
throw $this->buildException(...); // 429 with Retry-After
}
}
foreach ($limits as $limit) {
$this->limiter->hit($limit->key, $limit->decaySeconds);
}
return $next($request);
The first hit() writes two cache entries. One is the counter. The other is
key:timer, which holds the second the window ends. Both entries have the
decay as their time to live. The decay is the window length: 60 seconds for
throttle:60,1. tooManyAttempts() returns true only while the counter is at
the limit and the timer entry still exists. Both entries expire at the same
second. From that second on, the counter reads zero. The next request opens a
new window.
Three things follow from that code. The simulator shows each one.
The window starts at the first request. It is not tied to the clock minute. A first request at 12:00:45 opens a window that ends at 12:01:45. The middleware does not know where any minute starts.
A refused request is not counted. Every check runs before any hit(), so a
429 never adds to a counter. A refusal also leaves the end of the window where
it was.
A caller refused at 12:00:10 gets Retry-After: 50. A caller refused at
12:00:50 gets Retry-After: 10. Both are told to come back at 12:01:00.
Two windows are two budgets. The counter belongs to its window and expires with it. At 12:00:59 the first window is full. At 12:01:00 it is gone, so 60 more get through at once. The last 60 seconds now run from 12:00:01 to 12:01:00. The request that opened the first window, at 12:00:00, is outside them. That leaves 59 from the first window and 60 from the second: 119. No 60 seconds can hold more under this limit. 119 is twice the limit, minus the request that opened the first window.
Retry-After makes bursts worse. It carries the seconds left until the timer
entry expires. Every caller refused inside one window is given the same end
time. So callers who arrived at different times all come back in the same
second. If there are more of them than the limit allows, the extra ones get a
new Retry-After. It points at the next boundary. The spike repeats there.
The middleware pipeline page shows where the throttle sits before your controller runs.
Every number on this page was measured, not worked out by hand. A script runs
the real ThrottleRequests middleware on Laravel 13.34.0, with an array cache
and a fake clock. It checks each claim, for example 119 in two seconds,
Retry-After: 50 at 12:00:10, and the window from 12:00:45 to 12:01:45.
In your code
A fixed window is fine for most routes. A steady caller under the limit never sees a 429. A window that resets whole is also cheap to store. The fixed window matters when a burst of nearly twice the limit would hurt, for example on a route that calls a paid API or sends email.
The fix in Laravel is a second, shorter limit beside the long one:
// AppServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
return [
Limit::perSecond(5)->by($request->ip()),
Limit::perMinute(60)->by($request->ip()),
];
});
// routes/api.php
Route::middleware('throttle:api')->group(function () { /* ... */ });
The per-second limit also uses a fixed window. Its window is one second long.
Measured on
the same boundary: 10 requests get through in 12:00:59 and 12:01:00 together,
not 119. Both limits here are keyed by IP. When two limits share a key,
Laravel renames each one with fallbackKey(). That keeps the two counters
apart. A request refused by either limit is counted by neither, because every
check runs before any count.
On the client side, do not retry on the exact second that Retry-After gives.
Add a small random delay to it, so refused callers spread out again.
The fine print
- Every caller on this page shares one key, the way all requests from one IP or one user do. A limit by IP gives each IP its own windows.
- The simulator works in whole seconds. Real requests carry millisecond times. The boundary burst can fit inside a few milliseconds instead of two seconds.
- The measurement used Laravel’s array cache, with a fake clock. It has not been run against Redis or another store.
- A sliding window or a token bucket would spread the limit evenly. Laravel’s
throttlemiddleware uses neither. This page does not build one. - Queue jobs have their own rate limiting middleware. This page covers only the HTTP throttle.
Further reading
- Laravel: rate limiting routes
covers
RateLimiter::for,Limit, segmenting by key and several limits on one route. - Laravel: the rate limiter covers
the lower level
RateLimiterthis middleware calls. You can use it for any action, not only HTTP requests. - RFC 9110: Retry-After defines the header a 429 carries, as a number of seconds or as a date.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.