The standard rate limiting pattern from the Laravel documentation looks like this:
if (RateLimiter::tooManyAttempts('send-message:'.$user->id, $maxAttempts = 5)) {
return 'Too many attempts!';
}
RateLimiter::hit('send-message:'.$user->id);
// Send message...
It works reliably. Until someone fires 100 parallel requests at once at an endpoint that allows 5 requests per minute. Then all 100 get through.
This is a race condition I ran into while implementing rate limiting for captchaapi.eu (a PoW CAPTCHA API). The inspiration for uncovering it came from a post on X by @_newtonjob, which named exactly this problem:
Your Ratelimiting logic works until someone fires 100 concurrent requests on an endpoint that should be limited to 5 requests per minute. The fix: Ensure you/your agents also check the incremented count returned by
RateLimiter::hit()and that it doesn't exceed the max attempts.
I will break down why it is a problem, how to fix it with one line and what I took away from it.
Update (10 June 2026): Once I verified this, I sent a warning to the official Laravel docs right at that example. It went through, so the "Manually Incrementing Attempts" section now warns about this race condition and shows the atomic fix right away. I am leaving the rest of the article as it was written.
Why is it a problem?
What happens with a single request?
tooManyAttempts()reads the current count from the cache- It compares it with
$maxAttempts - It returns
true/false - If
false, the code callshit(), which increments the count
Two independent cache calls. Between step 1 and step 4 there is a window - typically a few microseconds - when another request manages to read the same stale value, pass the check and increment too.
With 100 parallel requests this happens massively:
- Request 1 reads count = 0, passes the check (0 < 5), calls
hit()→ count = 1 - Request 2 reads count = 0 (at the same moment), passes the check, calls
hit()→ count = 2 - ... and so on for all 100 requests
The result: the counter shows 100 at the end, but all 100 requests have already run and your backend processed 100× more than it wanted to. And if that endpoint computes something expensive (a PoW challenge, AI inference, an external API call), you have just paid for 100 operations instead of 5.
Why the increment is atomic but the check is not
If you look into the Laravel source (Illuminate\Cache\RateLimiter::increment() in 12.x):
public function increment($key, $decaySeconds = 60, $amount = 1)
{
$key = $this->cleanRateLimiterKey($key);
$this->cache->add(
$key.':timer', $this->availableAt($decaySeconds), $decaySeconds
);
$added = $this->withoutSerializationOrCompression(
fn () => $this->cache->add($key, 0, $decaySeconds)
);
$hits = (int) $this->cache->increment($key, $amount);
// ...
return $hits;
}
And hit() is just an alias for increment():
public function hit($key, $decaySeconds = 60)
{
return $this->increment($key, $decaySeconds);
}
The key is $this->cache->increment($key, $amount) - that is an atomic operation in the cache backend.
In captchaapi.eu I use Redis, where it maps to INCR (or INCRBY) - one of the oldest and best tested Redis commands, atomic at the level of a single-key write.
No two parallel requests read the same value, they always get a unique incremented result. Memcached has an equivalent incr with the same atomicity.
And here is the crucial point: hit() returns the count after the increment. The return value is atomic, deterministic and unique for every parallel request.
$hits = RateLimiter::hit($key);
// With 100 concurrent requests you get return values 1, 2, 3, ..., 100
// (in random order, but each value exactly once)
tooManyAttempts(), by contrast, is a separate read that may well return a stale value. And between the read and the write a window opens for the race condition.
The fix: one increment, check the return value
Instead of the two-step pattern (tooManyAttempts → hit) do just one step:
$attempts = RateLimiter::hit('send-message:'.$user->id);
if ($attempts > $maxAttempts) {
return 'Too many attempts!';
}
// Send message...
What happens now with 100 parallel requests:
- Every request gets a unique count after the increment
- The first 5 get values 1-5 and pass
- The remaining 95 get values 6-100 and are rejected
No window, no race condition. The atomic Redis increment is the only source of truth here and hit() hands it to you directly.
A subtle difference: in the original pattern the increment happens after the check, so the overshoot stays in the counter ("the counter shows 6 even though we did not want to allow a 6th request"). In the new pattern you increment always, but check the return value, so the counter may well show 100 - that is fine, because you rejected the surplus requests. From a business logic point of view it is equivalent; from a security point of view the new pattern is stricter.
hit() vs. increment() - which to use?
Functionally they are identical (hit() internally calls increment() with amount = 1). The choice is purely about readability:
hit()- semantically "I register one event", suits classic rate limitingincrement()- semantically "atomically raise the counter by N", suits when you want to stress atomicity or increase by more than 1
In captchaapi.eu I use increment(), because to me it is more readable that this is an atomic increment - and it explicitly says I care about the return value, not the side effect.
Limits of this solution
An important admission: this solution protects against a race condition within a single Redis instance (or a Redis cluster, where each key lives on one shard).
If you had separate Redis instances for different regions without coordination and an attacker triggered requests across regions, the atomicity of INCR would not secure the protection - you would have 100 requests × N regions.
In captchaapi.eu that is enough for me, because the whole application runs against a single Redis (Hetzner Nuremberg). For multi-region with distributed rate limiting you would need an algorithm like a sliding window log or a token bucket with a centralized source of truth - that is a different topic.
The second known limit: this protects against a race condition at the level of the counter. If the attacker changes IP (a botnet, a residential proxy), no per-IP rate limit will stop them - that is a fundamentally different problem, which in captchaapi.eu I solve with the PoW challenge itself.
What I took away from it
This small fix showed me several things I had not realized until then:
1. "It works" ≠ "it is safe." I had the original pattern from the Laravel docs in the application for some time and no bug ever surfaced - simply because no attacker ever came to captchaapi.eu.
Race conditions of this kind are quiet: the application logs no error, monitoring sees "all good", and only when someone rushes in with wrk -c 100 do you find out the limit never really applied.
In my next code review of an application where I rate limit an expensive operation somehow, I start with the question: "What happens when 100 requests arrive in the same microsecond window?"
2. Return values from atomic operations are code you do not write. An atomic increment in Redis gives you a unique sequence number for free.
It can be used not just for rejection but also for further business logic that you would otherwise solve with more code and more locks.
When you have an atomic counter and use only tooManyAttempts(), you are throwing away information Redis gave you for free.
3. A standard pattern in the documentation ≠ best practice. The Laravel docs showed that tooManyAttempts() + increment() pattern for years without a single warning, from 8.x onwards.
It is not a mistake - for most web applications (a per-user rate limit where the user does not actually make 100 parallel requests) it is sufficient.
But for a public API, where the primary threat model is precisely a parallel attack, the documentation did not show the safest solution.
When I ran into it, I sent the docs a warning along with an example of the atomic fix, so from 13.x that note is there. Even so, whenever I read any docs since, I always ask myself: "Who is the assumed user here and does their threat model match mine?"
4. I draw my security awareness from X. This particular fix reached me through a 280-character post by @_newtonjob, not through the Laravel docs or a security audit. Following members of the Laravel community who share concrete pattern mistakes from real applications gives me more than most security blog posts. It pays to have people in your feed who write "I ran into this, fixed it like this" - that is exactly the pattern matching you need for your own code. Thanks, @_newtonjob.
Summary
// ❌ Prone to the race condition - 100 parallel requests get through
if (RateLimiter::tooManyAttempts($key, 5)) {
return 'Too many!';
}
RateLimiter::hit($key);
// ✅ Race-safe - atomic increment + check of the return value
if (RateLimiter::hit($key) > 5) {
return 'Too many!';
}
One line less and one problem solved. If you have a Laravel rate limiter with the tooManyAttempts() + hit() pattern in your application, go through it and rewrite it to the single-call variant - especially when you limit an endpoint where every call is expensive.