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

Simulator · Rate limit window

The windows, the counters and every header run on the tested reducer, checked against the real ThrottleRequests middleware on Laravel 13.

12:01:00: 20 requests got 200 and opened a window that ends at 12:02:00. 40 left in it.

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-After told 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 with Retry-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 throttle middleware 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

Spotted a problem, or have a way to make this clearer? Suggest an improvement.