Rate Limiting from Scratch: Token Bucket & Sliding Window in PHP
AI generated
<?php
8.4
PHP · Rate Limiting · API Security · Redis
Rate Limiting from Scratch
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.

18 min read Token Bucket · Sliding Window · Redis · 429 PHP 8.4 · framework agnostic

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.

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.

11. FAQ: Rate Limiting from Scratch

1Best algorithm for public APIs?
Token bucket, because it allows controlled bursts and reflects real usage patterns better than rigid windows.
2Why is fixed window problematic?
A client can exhaust the limit twice around a window boundary, causing briefly doubled load.
3Do I need Redis?
Yes with multiple servers; for a single process in-memory counting would theoretically suffice.
4Which status code on rejection?
429 Too Many Requests with a Retry-After header indicating the wait time in seconds.
5IP or API key as the key?
API key or user ID is more accurate, because shared NAT addresses affect multiple users at once under IP-based limiting.
6Avoiding race conditions in token bucket?
Use an atomic Lua script via EVAL bundling read, compute and write into a single transaction.
7Should all endpoints share the same limit?
No, computationally intensive endpoints need lower limits than simple, cacheable GET endpoints.
8What does X-RateLimit-Remaining mean?
Shows remaining requests in the current window, letting clients throttle proactively.
9Can limits be tied to pricing plans?
Yes, standard for commercial APIs, limits are selected at runtime based on the customer account.
10Is a local counter without Redis enough?
Only for a single process; with multiple workers or servers it leads to inconsistent, bypassed limits.