Rate limiting protects your API from abuse, prevents resource exhaustion, and ensures fair usage across clients. A well-designed rate limiting strategy balances protection with user experience. This guide covers algorithms, implementation patterns, and best practices.
Why Rate Limit?
Protection Against
- Denial of Service: Preventing resource exhaustion from flooding
- Brute force attacks: Limiting password guessing attempts
- Scraping: Protecting content from automated harvesting
- API abuse: Enforcing fair usage among clients
- Cost control: Limiting expensive operations
Business Benefits
- Predictable infrastructure costs
- Better experience for all users
- Enforcement of pricing tiers
- Protection of downstream services
Rate Limiting Algorithms
Fixed Window
Counts requests in fixed time intervals (e.g., per minute). This is the simplest algorithm to understand and implement - you divide time into discrete buckets and count requests within each bucket.
Window: 00:00 - 01:00 | 01:00 - 02:00
Requests: ████████░░ | ██░░░░░░░░
(80/100) | (20/100)
Pros: Simple to implement and understand Cons: Burst at window edges (request 100 at 0:59, 100 more at 1:00)
The fixed window algorithm uses the current time to determine which window a request falls into. Each window gets its own counter that increments with each request. Here's a straightforward Redis implementation that you can use as a starting point.
// Simple fixed window in Redis
public function isAllowed(string $key, int $limit, int $windowSeconds): bool
{
$window = floor(time() / $windowSeconds);
$redisKey = "{$key}:{$window}";
$count = Redis::incr($redisKey);
if ($count === 1) {
Redis::expire($redisKey, $windowSeconds);
}
return $count <= $limit;
}
The key insight here is dividing time by the window size to create discrete buckets. Setting expiration on first request ensures old keys clean themselves up automatically. You'll notice we only set the expiration when $count === 1 to avoid resetting the TTL on every request.
Sliding Window Log
Tracks timestamp of each request, counts requests within rolling window.
This approach stores every request timestamp in a sorted set, then counts how many fall within the window. It's precise but memory-intensive. Use this when accuracy matters more than resource efficiency.
public function isAllowed(string $key, int $limit, int $windowSeconds): bool
{
$now = microtime(true);
$windowStart = $now - $windowSeconds;
// Remove old entries
Redis::zremrangebyscore($key, '-inf', $windowStart);
// Count current window
$count = Redis::zcard($key);
if ($count < $limit) {
Redis::zadd($key, $now, $now);
Redis::expire($key, $windowSeconds);
return true;
}
return false;
}
Pros: Precise, no edge bursts Cons: Memory intensive (stores every request timestamp)
The sorted set cleanup (zremrangebyscore) is crucial - without it, memory grows unbounded. For high-traffic APIs, consider the sliding window counter instead. You'll also notice we use microtime(true) for sub-second precision, which prevents collisions when multiple requests arrive within the same second.
Sliding Window Counter
Hybrid approach combining fixed windows with weighted counting.
This algorithm gets the best of both worlds: the memory efficiency of fixed windows with the smoothness of sliding windows. It weights the previous window based on how far into the current window you are. The diagram below illustrates how the weighting works.
Previous window: 70 requests (30% weight = 21)
Current window: 40 requests (70% weight = 28)
Weighted count: 21 + 28 = 49
Here's how you can implement this hybrid approach in PHP with Redis. The key is calculating the window progress to determine how much weight to give the previous window.
public function isAllowed(string $key, int $limit, int $windowSeconds): bool
{
$now = time();
$currentWindow = floor($now / $windowSeconds);
$previousWindow = $currentWindow - 1;
$currentCount = (int) Redis::get("{$key}:{$currentWindow}") ?? 0;
$previousCount = (int) Redis::get("{$key}:{$previousWindow}") ?? 0;
// Weight based on position in current window
$windowProgress = ($now % $windowSeconds) / $windowSeconds;
$previousWeight = 1 - $windowProgress;
$weightedCount = ($previousCount * $previousWeight) + $currentCount;
if ($weightedCount < $limit) {
Redis::incr("{$key}:{$currentWindow}");
Redis::expire("{$key}:{$currentWindow}", $windowSeconds * 2);
return true;
}
return false;
}
Pros: Memory efficient, smooth rate limiting Cons: Slightly less precise than sliding log
The windowProgress calculation is the key - at 0:30 of a 1-minute window, you're 50% through, so the previous window contributes 50% of its count. We set the expiration to twice the window size to ensure the previous window's data is still available when we need it.
Token Bucket
Tokens accumulate at a steady rate. Each request consumes a token. Allows controlled bursts.
Token bucket is intuitive to explain to users: "You have a bucket that holds 10 tokens. Each request uses one token. Tokens refill at one per second." This mental model helps users understand why they can burst occasionally but must pace themselves over time. The visualization below shows how the bucket fills and drains.
Bucket capacity: 10 tokens
Refill rate: 1 token/second
[██████████] Full bucket (10 tokens)
[████░░░░░░] After burst of 6 requests
[██████░░░░] 2 seconds later (refilled 2)
Here's a complete token bucket implementation. The key insight is lazy calculation - rather than running a background process to add tokens, you calculate how many should have been added since the last request.
class TokenBucket
{
public function consume(string $key, int $capacity, float $refillRate): bool
{
$now = microtime(true);
$data = Redis::hmget($key, ['tokens', 'last_update']);
$tokens = $data['tokens'] ?? $capacity;
$lastUpdate = $data['last_update'] ?? $now;
// Calculate tokens to add
$elapsed = $now - $lastUpdate;
$tokens = min($capacity, $tokens + ($elapsed * $refillRate));
if ($tokens >= 1) {
Redis::hmset($key, [
'tokens' => $tokens - 1,
'last_update' => $now,
]);
Redis::expire($key, $capacity / $refillRate * 2);
return true;
}
return false;
}
}
Pros: Handles bursts gracefully, intuitive model Cons: More complex implementation
Note the lazy refill calculation - rather than running a background process to add tokens, you calculate how many should have been added since the last request. This is efficient but requires careful floating-point handling. The expiration time is set to twice the time it takes to refill a full bucket, ensuring keys don't expire while still in active use.
Leaky Bucket
Requests enter a queue processed at constant rate. Excess requests overflow.
Pros: Very smooth output rate Cons: Adds latency, more complex
The leaky bucket is ideal when you need a very consistent outbound rate, like when integrating with a third-party API that has strict rate limits. It's less commonly used for incoming request limiting because of the added latency.
Implementation Patterns
Laravel Rate Limiting
Laravel provides built-in rate limiting that handles most common scenarios. You define named limiters in your service provider and apply them via middleware. This abstraction saves you from implementing the algorithms yourself while still giving you flexibility.
// routes/api.php
Route::middleware(['throttle:api'])->group(function () {
Route::get('/users', [UserController::class, 'index']);
});
// app/Providers/RouteServiceProvider.php
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Different limits per tier
RateLimiter::for('uploads', function (Request $request) {
return $request->user()->isPremium()
? Limit::perMinute(100)
: Limit::perMinute(10);
});
}
The by() method determines what key to rate limit by. Using user ID for authenticated requests and IP for anonymous ones is a common pattern that prevents abuse while giving logged-in users consistent limits regardless of their IP. The tier-based example shows how you can provide different limits to different user segments.
Custom Response Headers
Inform clients about their rate limit status:
Good API design tells clients how many requests they have remaining. This lets clients self-regulate instead of discovering limits through 429 errors. Here's how to add rate limit headers to every response through middleware.
// Middleware
public function handle($request, Closure $next)
{
$key = $this->resolveRequestSignature($request);
$limit = 100;
$remaining = $limit - RateLimiter::attempts($key);
$resetAt = RateLimiter::availableAt($key);
$response = $next($request);
return $response->withHeaders([
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => max(0, $remaining),
'X-RateLimit-Reset' => $resetAt,
]);
}
These headers follow the common convention used by GitHub, Twitter, and other major APIs. Clients can use them to implement intelligent request spacing. The max(0, $remaining) ensures you never send a negative number, which could confuse clients.
Handling Rate Limit Exceeded
When a client exceeds their limit, return a 429 status with clear information about when they can retry. A well-formatted error response helps clients handle the situation gracefully.
// Return 429 with retry information
if (RateLimiter::tooManyAttempts($key, $limit)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'error' => 'Too many requests',
'retry_after' => $seconds,
], 429)->header('Retry-After', $seconds);
}
The Retry-After header is standard HTTP - well-behaved clients will respect it. Including the value in the JSON body as well makes it accessible to clients that don't inspect headers. This dual approach maximizes compatibility across different client implementations.
Advanced Patterns
Hierarchical Limits
Apply multiple limit tiers:
Real-world rate limiting often requires multiple overlapping limits: a burst limit per second, a sustained limit per minute, and a quota per day. Users who stay under the minute limit might still hit the daily quota. Here's how to configure layered limits in Laravel.
// Per second burst limit
RateLimiter::for('api-burst', fn($request) =>
Limit::perSecond(10)->by($request->user()->id)
);
// Per minute sustained limit
RateLimiter::for('api-minute', fn($request) =>
Limit::perMinute(100)->by($request->user()->id)
);
// Per day quota
RateLimiter::for('api-daily', fn($request) =>
Limit::perDay(10000)->by($request->user()->id)
);
Route::middleware(['throttle:api-burst', 'throttle:api-minute', 'throttle:api-daily'])
->group(function () { /* ... */ });
Order matters here - check the tightest limit (burst) first, then progressively looser limits. This fails fast and avoids unnecessary checks. A request that exceeds the burst limit doesn't need to check the minute or daily limits.
Endpoint-Specific Limits
Some endpoints warrant tighter limits than your default. Login endpoints should be heavily throttled to prevent brute force attacks, while expensive export operations might need hourly limits. You can specify these directly in your route definitions.
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:5,1'); // 5 attempts per minute
Route::post('/export', [ExportController::class, 'export'])
->middleware('throttle:2,60'); // 2 per hour
The shorthand throttle:5,1 means 5 requests per 1 minute. This is a quick way to apply custom limits without defining named limiters in your service provider.
Distributed Rate Limiting
For multiple application servers, use Redis:
When you run multiple application servers, you need shared state for rate limiting. Without it, each server tracks limits independently, effectively multiplying your actual limits by the number of servers. Redis provides the shared state you need.
// config/cache.php
'rate_limiter' => [
'driver' => 'redis',
'connection' => 'rate-limiter',
],
Using a dedicated Redis connection for rate limiting keeps this traffic separate from your cache operations. This isolation prevents cache flushes from accidentally clearing your rate limit counters.
Graceful Degradation
Instead of hard blocking, reduce service level:
Sometimes a hard cutoff is too severe. Graceful degradation lets you warn users they're approaching limits by reducing response detail, disabling optional features, or increasing latency before completely blocking them. Here's a middleware implementation of this pattern.
public function handle($request, Closure $next)
{
$attempts = RateLimiter::attempts($key);
if ($attempts > 100) {
abort(429);
} elseif ($attempts > 80) {
// Reduce response detail
$request->attributes->set('degraded', true);
}
return $next($request);
}
Your controllers can then check the degraded attribute and omit expensive computed fields or reduce page sizes. This gives users a warning that they're approaching their limit while still providing some service.
API Design Considerations
Authenticated vs Anonymous
Authenticated users typically get higher limits since you can trust them more and track them better. Anonymous requests from the same IP might represent many different users behind NAT. Here's a common pattern for differentiating these cases.
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(1000)->by($request->user()->id)
: Limit::perMinute(60)->by($request->ip());
});
The 10x difference between authenticated and anonymous limits is typical, though your ratio will depend on your specific use case and abuse patterns.
By API Key vs By User
For machine-to-machine traffic, rate limiting by API key allows different applications to have independent quotas even when owned by the same user. This is essential for users who operate multiple integrations.
// Rate limit by API key for M2M traffic
RateLimiter::for('api-key', function (Request $request) {
$apiKey = $request->header('X-API-Key');
return Limit::perMinute(1000)->by($apiKey);
});
This approach lets a user have separate limits for their production app, staging app, and development experiments without them interfering with each other.
Cost-Based Rate Limiting
Some operations cost more than others:
Not all API calls are equal. A simple read might hit cache, while a search query scans millions of records. Cost-based limiting assigns different "weights" to different operations. Here's how you might structure this.
// Simple endpoint: 1 token
// Search endpoint: 5 tokens
// Export endpoint: 50 tokens
$cost = $this->getEndpointCost($request);
if (!$bucket->consume($key, $cost)) {
abort(429);
}
This approach requires a token bucket algorithm that supports consuming multiple tokens at once. Document these costs clearly so users can plan their API usage accordingly.
IP vs User Identification
Consider:
- IP can be shared (NAT, corporate networks)
- User ID requires authentication
- API keys provide per-application tracking
- Fingerprinting for sophisticated abuse
The right choice depends on your threat model. Most applications use a combination - IP for unauthenticated requests and user ID or API key for authenticated ones.
Documentation
Always document your rate limits:
Clear documentation prevents frustrated users and support tickets. Include your limits in a table, explain the headers you return, and show how to handle 429 responses. Here's an example of comprehensive rate limit documentation.
## Rate Limits
| Tier | Requests/min | Burst | Daily |
|------|-------------|-------|-------|
| Free | 60 | 10/sec | 1,000 |
| Pro | 600 | 50/sec | 50,000 |
| Enterprise | Custom | Custom | Custom |
### Response Headers
Every response includes rate limit headers:
- `X-RateLimit-Limit`: Your plan's limit
- `X-RateLimit-Remaining`: Requests remaining
- `X-RateLimit-Reset`: Unix timestamp when limit resets
### Handling 429 Responses
When rate limited, you'll receive:
```json
{
"error": "rate_limit_exceeded",
"retry_after": 30
}
Respect the Retry-After header and implement exponential backoff.
Providing code examples for handling rate limits in popular languages helps your users implement proper backoff without trial and error. Consider adding sample code for JavaScript, Python, and any other languages your users commonly use.
## Conclusion
Rate limiting is essential for API protection and fair resource allocation. Choose your algorithm based on your needs;token bucket for burst tolerance, sliding window for precision. Implement with clear headers so clients can self-regulate, and document your limits thoroughly. Remember that rate limiting is as much about user experience as it is about protection.