Token bucket, sliding window and the right HTTP headers
Without rate limiting, a single misbehaving client loop or a targeted attack is enough to bring a PHP API to its knees. Building your own rate limiting is straightforward with Redis and a few lines of PHP, with no third party gateway required, and the choice between fixed window, sliding window and token bucket determines how fair and how burst tolerant the protection actually turns out to be.
Table of Contents
- 1. Why rate limiting is not an optional feature
- 2. Fixed window: the simplest approach and its weakness
- 3. Sliding window: more precise against boundary bursts
- 4. Token bucket: allowing controlled bursts
- 5. Redis as the backend for distributed rate limiting
- 6. The right HTTP headers: 429 and Retry-After
- 7. Rate limit keys: IP, API key or user ID
- 8. Tiered limits by endpoint and plan
- 9. Rate limiting algorithms side by side
- 10. Summary
- 11. FAQ
1. Why rate limiting is not an optional feature
Every publicly reachable PHP API needs rate limiting as soon as it has more than one trusted internal consumer. Without limits, a single misconfigured client, an aggressive scraper, or a targeted denial-of-service attempt can overload the database, the cache, and ultimately the entire application server. Rate limiting sets a hard ceiling on how many requests a client may make within a time window, protecting the infrastructure against excessive load, whether caused maliciously or by accident.
Beyond pure protection, rate limiting also serves fair resource distribution among consumers: without limits, a single high-traffic customer can degrade response times for every other user of the API. Commercial APIs additionally use rate limiting to enforce pricing tiers technically, for example a free tier with 100 requests per hour versus an enterprise tier with 10,000. Building your own rate limiting in PHP is less effort than it initially looks, especially with Redis as a shared counter backend.
2. Fixed window: the simplest approach and its weakness
The simplest algorithm for rate limiting is fixed window: a counter is kept per client and reset to zero at the start of each fixed time window, for example every full minute. Once the counter reaches the limit before the window ends, further requests are rejected. The implementation is trivial and needs only an atomic increment command with an expiry, which makes fixed window the obvious first choice for rate limiting.
The weakness shows up at window boundaries: a client can exhaust the full limit right before a window ends and, immediately afterward, in the new window, use the full limit again right away. In total this allows twice as many requests as intended, concentrated in a very short span around the window boundary. For many internal APIs this effect is tolerable, but for rate limiting on critical, resource-intensive endpoints it is a real security problem.
<?php
declare(strict_types=1);
// Fixed window rate limiting with Redis — simple but has boundary bursts
final class FixedWindowLimiter
{
public function __construct(
private readonly Redis $redis,
private readonly int $limit,
private readonly int $windowSeconds,
) {}
public function allow(string $key): bool
{
$windowKey = "rl:{$key}:" . intdiv(time(), $this->windowSeconds);
$count = $this->redis->incr($windowKey);
if ($count === 1) {
$this->redis->expire($windowKey, $this->windowSeconds);
}
return $count <= $this->limit;
}
}
$limiter = new FixedWindowLimiter(new Redis(), limit: 100, windowSeconds: 60);
if (!$limiter->allow("api-key:{$apiKey}")) {
http_response_code(429);
exit;
}
3. Sliding window: more precise against boundary bursts
Sliding window solves the boundary burst problem by not relying on fixed calendar windows but instead looking at every individual request timestamp within the last N seconds before the current point in time. The most precise rate limiting following this principle stores every timestamp in a sorted set, removes expired entries, and counts the remaining ones. The precision has its price: more memory per client and more Redis operations per request compared to fixed window.
A more efficient approximation is the sliding window counter, which combines two fixed-window counters and weights the previous window's share proportionally to the elapsed time. This approach needs only two counters instead of a full timestamp list and delivers, in practice, a good approximation of true sliding window behavior. For most rate limiting use cases in PHP APIs, this weighted variant is the best compromise between precision and resource consumption.
<?php
declare(strict_types=1);
// Sliding window counter — weights the previous window by elapsed time
final class SlidingWindowLimiter
{
public function __construct(
private readonly Redis $redis,
private readonly int $limit,
private readonly int $windowSeconds,
) {}
public function allow(string $key): bool
{
$now = time();
$currentWindow = intdiv($now, $this->windowSeconds);
$elapsedFraction = ($now % $this->windowSeconds) / $this->windowSeconds;
$currentKey = "rl:{$key}:{$currentWindow}";
$previousKey = "rl:{$key}:" . ($currentWindow - 1);
$currentCount = (int) $this->redis->get($currentKey);
$previousCount = (int) $this->redis->get($previousKey);
// Weighted estimate: previous window's share still "counts" as it slides out
$weightedCount = $previousCount * (1 - $elapsedFraction) + $currentCount;
if ($weightedCount >= $this->limit) {
return false;
}
$this->redis->incr($currentKey);
$this->redis->expire($currentKey, $this->windowSeconds * 2);
return true;
}
}
4. Token bucket: allowing controlled bursts
Token bucket follows a different philosophy: instead of distributing requests strictly evenly, it allows short, controlled bursts as long as the average rate over time stays within limits. A bucket is refilled with tokens at a fixed rate, up to a maximum capacity. Every request consumes a token; if the bucket is empty, the request is rejected. This makes this rate limiting approach particularly suited to APIs where clients occasionally request in bursts rather than evenly, for example when initially loading many resources of an application.
The decisive difference from sliding window is the ability to accumulate unused capacity: a client that has been inactive for a while can briefly make more requests than the average value would allow, as long as the bucket is full enough. This behavior often reflects real usage patterns better than rigid windows and makes token bucket the preferred choice for rate limiting on APIs with naturally fluctuating traffic.
<?php
declare(strict_types=1);
// Token bucket rate limiting: allows controlled bursts up to bucket capacity
final class TokenBucketLimiter
{
public function __construct(
private readonly Redis $redis,
private readonly int $capacity,
private readonly float $refillRatePerSecond,
) {}
public function allow(string $key): bool
{
$bucketKey = "rl:bucket:{$key}";
$data = $this->redis->hGetAll($bucketKey);
$tokens = isset($data['tokens']) ? (float) $data['tokens'] : (float) $this->capacity;
$lastRefill = isset($data['last_refill']) ? (float) $data['last_refill'] : microtime(true);
$now = microtime(true);
$elapsed = $now - $lastRefill;
$tokens = min($this->capacity, $tokens + $elapsed * $this->refillRatePerSecond);
if ($tokens < 1.0) {
$this->redis->hMSet($bucketKey, ['tokens' => $tokens, 'last_refill' => $now]);
return false;
}
$tokens -= 1.0;
$this->redis->hMSet($bucketKey, ['tokens' => $tokens, 'last_refill' => $now]);
$this->redis->expire($bucketKey, 3600);
return true;
}
}
// 60 tokens capacity, refilled at 1 per second — allows short bursts up to 60
$limiter = new TokenBucketLimiter(new Redis(), capacity: 60, refillRatePerSecond: 1.0);
5. Redis as the backend for distributed rate limiting
As soon as a PHP application runs on multiple servers or containers, an in-memory counter per process is no longer sufficient, because each server would only see its own share of traffic. Rate limiting then needs a central, shared state, and Redis is ideal for this: low latency, atomic increment operations and built-in time-to-live support for automatically cleaning up expired counters.
It is important that the Redis operations for rate limiting remain atomic, especially with token bucket and its read-compute-write sequence. Without atomic execution through a Lua script transaction, two simultaneous requests can read the same token count and both incorrectly consider the consumption allowed, effectively doubling the limit. In production environments it is therefore worth executing the bucket logic as an EVAL Lua script instead of issuing several separate Redis commands from PHP.
6. The right HTTP headers: 429 and Retry-After
Rate limiting without the right HTTP headers leaves clients unsure about when they can try again. The status code 429 Too Many Requests signals the rejection, and the Retry-After header indicates after how many seconds a retry makes sense. Additionally, the unofficial but widely adopted headers X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset have become common, letting clients proactively stay under the limit instead of only reacting after being rejected.
These headers should be sent on every request, not just on rejections, so that well implemented clients can adjust their own behavior before ever hitting the limit. Rate limiting that provides this transparency noticeably reduces the number of rejected requests in practice, because cooperative clients throttle their own request rate as soon as X-RateLimit-Remaining approaches zero.
<?php
declare(strict_types=1);
// Standard rate-limit response headers, sent on every request
final class RateLimitHeaders
{
public function apply(int $limit, int $remaining, int $resetTimestamp): void
{
header("X-RateLimit-Limit: {$limit}");
header('X-RateLimit-Remaining: ' . max(0, $remaining));
header("X-RateLimit-Reset: {$resetTimestamp}");
}
public function reject(int $retryAfterSeconds): void
{
http_response_code(429);
header("Retry-After: {$retryAfterSeconds}");
header('Content-Type: application/problem+json');
echo json_encode([
'type' => 'https://mironsoft.de/problems/rate-limit-exceeded',
'title' => 'Too Many Requests',
'status' => 429,
'detail' => "Rate limit exceeded, retry after {$retryAfterSeconds} seconds",
]);
}
}
7. Rate limit keys: IP, API key or user ID
The choice of key under which rate limiting is counted determines its actual effectiveness. IP-based rate limiting is simple to implement but fails for clients behind shared NAT gateways or corporate proxies, where several legitimate users share the same IP address. API-key-based rate limiting is the more accurate choice for authenticated APIs, because every consumer gets their own quota regardless of network environment.
For public, unauthenticated endpoints, IP-based rate limiting often remains the only practical option, but should then be calculated with more generous limits to cushion NAT effects. A proven strategy combines both layers: a coarse IP limit as a first line of defense against automated abuse, and a finer API-key or user limit for the actual per-customer quota control.
8. Tiered limits by endpoint and plan
Not every endpoint deserves the same rate limiting. A simple GET endpoint served from a cache can tolerate a much higher limit than a computationally intensive search or export endpoint that heavily taxes the database on every call. Well designed rate limiting therefore defines limits per endpoint category instead of one blanket global value for the entire API.
Rate limiting can additionally be tied to pricing tiers, which is standard for commercial APIs: a free-tier customer receives lower limits than an enterprise customer, configurable via the same Redis-based mechanism, only with different limit and refill parameters per customer account. This flexibility can be elegantly mapped in PHP through a configuration class that selects the appropriate limit at runtime based on user or API-key metadata, instead of hardcoding limits in the code.
9. Rate limiting algorithms side by side
The following table compares the three algorithms covered and helps decide which rate limiting approach fits which use case.
| Algorithm | Precision | Burst behavior | Recommendation |
|---|---|---|---|
| Fixed window | Low | Double burst at window edges | Simple internal APIs without critical load |
| Sliding window counter | High | No boundary burst effect | Public APIs with steady traffic |
| Token bucket | High | Controlled bursts allowed | APIs with naturally fluctuating traffic |
| No rate limiting | – | Unbounded | Only for purely internal, trusted callers |
For most public PHP APIs, token bucket is the best starting point, because it reflects real usage patterns with occasional load spikes without endangering the average rate. Fixed window remains attractive for internal, non-critical rate limiting use cases because of its simplicity.
Mironsoft
PHP backend development and API hardening
Rate limiting that actually protects your API?
We build Redis-based rate limiting matched to your traffic profile, with correct HTTP headers, tiered limits per plan and protection against burst attacks.
Algorithm choice
Token bucket, sliding window or fixed window matched to your traffic profile
Redis integration
Atomic Lua scripts for distributed rate limiting across multiple servers
Plan coupling
Tiered limits per customer account without hardcoded values in code
10. Summary
Implementing rate limiting in PHP yourself is not an academic exercise, it is a practical necessity for any API with external consumers. Fixed window is the simplest entry point but has weaknesses at window boundaries. Sliding window counter corrects that weakness with moderate additional effort. Token bucket allows controlled bursts and fits well with naturally fluctuating traffic. Redis provides the shared state management needed across multiple servers.
Just as important as the chosen algorithm is consistent communication through HTTP headers: 429, Retry-After and the X-RateLimit headers give clients the information they need to stay within limits on their own. Anyone who additionally tiers rate limiting by endpoint load and pricing plan achieves a system that protects infrastructure without unnecessarily throttling legitimate users.
Rate Limiting in PHP — The Essentials at a Glance
Algorithm
Token bucket for bursts, sliding window counter for precision, fixed window for simple internal cases.
Redis backend
Atomic increment and Lua script operations for consistent rate limiting across multiple servers.
HTTP headers
429, Retry-After, X-RateLimit-Limit/Remaining/Reset sent on every response.
Key choice
API key or user ID instead of pure IP, to avoid NAT effects and shared addresses.