Rate limiting & throttling — same goal, different mechanisms
Concept
Rate limiting vs throttling — two terms often used interchangeably, but with a precise distinction.
Rate limiting: An upper bound on how many requests a client can make in a given time window. When the limit is exceeded, requests are rejected (usually with 429 Too Many Requests). It's about PREVENTION — hard limit enforcement.
Throttling: Slowing down or delaying requests rather than rejecting them. When a client exceeds a threshold, requests are queued or artificially slowed. Netflix throttles video bitrate under congestion instead of rejecting the stream.
In practice: Most people (including Laravel's docs) use "rate limiting" to mean both. The RFC 6585 status code "429 Too Many Requests" is for rate limiting.
Rate limiting strategies:
- Fixed window: Count requests in a fixed time window (every minute from :00 to :59). Easy to implement. Susceptible to burst at window boundaries.
- Sliding window: Count requests in the last N seconds from now. Smoother, prevents boundary bursts.
- Token bucket: A bucket holds N tokens. Each request consumes a token. Tokens refill at a fixed rate. Allows bursts up to bucket size.
- Leaky bucket: Requests enter a queue (the bucket), processed at a fixed rate (the leak). Excess requests overflow (rejected).
Rate limiting keys:
- By IP: Good for unauthenticated APIs and preventing abuse.
- By user ID: Better for authenticated APIs.
- By API key: For third-party developers.
- By route: Different limits for different endpoints (stricter for
/payments, looser for/products).
Response headers (standard per RFC 6585 and convention):
X-RateLimit-Limit: Max requests allowed.X-RateLimit-Remaining: Requests left in the current window.X-RateLimit-Reset: Timestamp when the window resets.Retry-After: (on 429) Seconds to wait before retrying.
Code Example
<?php
// LARAVEL THROTTLE MIDDLEWARE — fixed window rate limiting
Route::middleware('throttle:60,1')->group(function () {
Route::get('/api/users', [UserController::class, 'index']);
// 60 requests per 1 minute per client (by IP or user)
// Returns 429 when exceeded
});
// Named rate limiters (Laravel 8+)
// In RouteServiceProvider (or AppServiceProvider):
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?: $request->ip() // per user if auth, else per IP
);
});
RateLimiter::for('uploads', function (Request $request) {
return [
Limit::perMinute(10)->by($request->user()->id), // 10 per minute
Limit::perDay(100)->by($request->user()->id), // 100 per day (stacked)
];
});
Route::middleware('throttle:api')->group(fn() => Route::post('/search', fn() => []));
Route::middleware('throttle:uploads')->post('/upload', fn() => []);
// TOKEN BUCKET — Redis implementation
class TokenBucketLimiter
{
public function __construct(private \Illuminate\Support\Facades\Redis $redis) {}
public function attempt(string $key, int $maxTokens, float $refillRate): bool
{
$lua = <<<LUA
local key = KEYS[1]
local maxTokens = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3])
local tokens = tonumber(redis.call('hget', key, 'tokens') or maxTokens)
local lastRefill = tonumber(redis.call('hget', key, 'last') or now)
local elapsed = now - lastRefill
local newTokens = math.min(maxTokens, tokens + elapsed * refillRate)
if newTokens < 1 then return 0 end
redis.call('hset', key, 'tokens', newTokens - 1, 'last', now)
redis.call('expire', key, 3600)
return 1
LUA;
return (bool) \Redis::eval($lua, 1, "rate:{$key}", $maxTokens, $refillRate, time());
}
}
// Custom rate limit headers
class RateLimitMiddleware
{
public function handle(Request $request, \Closure $next): mixed
{
$key = 'rate:' . ($request->user()?->id ?: $request->ip());
$limit = 60;
$remaining = RateLimiter::remaining($key, $limit);
if ($remaining <= 0) {
$retryAfter = RateLimiter::availableIn($key);
return response()->json(['error' => 'Too many requests'], 429)
->header('Retry-After', $retryAfter)
->header('X-RateLimit-Limit', $limit)
->header('X-RateLimit-Remaining', 0)
->header('X-RateLimit-Reset', now()->addSeconds($retryAfter)->timestamp);
}
RateLimiter::hit($key, 60); // record hit, window = 60 seconds
return $next($request)
->header('X-RateLimit-Limit', $limit)
->header('X-RateLimit-Remaining', $remaining - 1);
}
}