Rate Limiting: Fixed Window, Sliding Window, and Token Bucket

Learn why APIs need rate limiting, implement fixed-window, sliding-window, and token-bucket algorithms in PHP backed by Redis, apply limits per-IP/per-user/per-token, and correctly respond with HTTP 429 and a Retry-After header.

Any endpoint that costs you money or compute — a login form, a search box, a public API — is a target for abuse, whether from a malicious script hammering it thousands of times a second or just a buggy client stuck in a retry loop. Rate limiting caps how many requests a given client can make in a given time window, protecting your database and your other users. Because a real PHP application usually runs behind a load balancer across multiple servers, the request counter can't live in a PHP variable or a local file — every server needs to see the same count, so the counter lives in a fast shared store like Redis. This lesson builds three increasingly sophisticated limiting algorithms and wires them into a real HTTP 429 response.

Why in-process counting doesn't work

PHP's request lifecycle destroys all variables at the end of every request — there is no long-lived process holding a counter in memory the way a Node.js or Java server might. Even if you used a static class property to count requests, it would reset on every request and, worse, be completely invisible to the other three servers behind your load balancer that might receive the client's next requests.

This means rate limiting in PHP is fundamentally a shared-state problem. The counter (and its expiry) must live somewhere all app servers can read and write atomically: Redis is the standard choice because it is fast (sub-millisecond round trips), supports atomic increment-and-expire operations, and every major PHP framework has a mature client for it (predis or the phpredis extension).

Fixed window: the simplest algorithm

A fixed window divides time into fixed-size buckets (e.g. one bucket per minute) and counts requests within the current bucket. When a request arrives, you build a key like rate:user:42:2026-09-20T14:31 (truncated to the minute), increment it, and set it to expire after the window length. If the count exceeds your limit, you reject the request.

Fixed window is simple and cheap (one INCR, maybe one EXPIRE) but has a well-known flaw at window boundaries: a client can send N requests in the last second of one window and another N in the first second of the next window, achieving nearly 2N requests in a very short span even though the configured limit is N per window.

Sliding window: smoothing the boundary problem

A sliding window fixes the boundary burst problem by looking at a continuously moving time range rather than discrete buckets. The simplest correct implementation is the sliding window log: store a timestamp for every request in a sorted set, remove entries older than the window on each check, and count what remains. Redis's ZADD/ZREMRANGEBYSCORE/ZCARD combination does this atomically and efficiently.

A cheaper approximation — the sliding window counter — blends the previous fixed window's count with the current one, weighted by how far into the current window you are. It uses far less memory than storing every timestamp, at the cost of being an approximation rather than an exact count.

Token bucket: allowing controlled bursts

A token bucket models a bucket that holds up to a fixed number of tokens. Tokens refill at a steady rate (e.g. 10 tokens per second, up to a maximum of 50). Every request consumes one token; if the bucket is empty, the request is rejected. This is different from window algorithms because it naturally allows a burst of activity (as long as tokens are banked up) while still enforcing a long-term average rate.

Token bucket is what most production API gateways (Stripe, GitHub, AWS API Gateway) actually use, because it matches how real clients behave: mostly idle, then briefly bursty. Implementing it correctly requires computing 'how many tokens have accumulated since I last checked' using the elapsed wall-clock time, then clamping to the bucket's capacity.

Choosing what to key the limit on

The identity you rate-limit by matters as much as the algorithm. Per-IP limiting is the easiest to set up (using $_SERVER['REMOTE_ADDR'], or the first trusted entry of X-Forwarded-For behind a load balancer) and stops anonymous abuse, but penalizes everyone behind a shared NAT or corporate proxy together.

Per-user limiting (keyed on the authenticated user's ID) is fairer for logged-in traffic and lets you offer different limits per subscription tier. Per-API-token limiting (keyed on the API key/bearer token) is the standard for public APIs, since it survives the client changing IP addresses and lets you revoke or throttle one key without affecting others.

Production systems commonly layer these: a generous per-IP limit to stop anonymous floods, plus a tighter per-token or per-user limit for authenticated quota enforcement.

Responding correctly with 429

When a client exceeds its limit, the correct HTTP status is 429 Too Many Requests (RFC 6585), not 403 Forbidden — 429 specifically tells well-behaved clients 'you can retry later, this isn't permanent.' You should also send a Retry-After header telling the client how many seconds to wait, computed from the window's remaining TTL or the token bucket's refill rate.

Well-designed rate limiters also expose X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers on every response (not just the 429), so clients can proactively back off before hitting the wall instead of discovering the limit by trial and error.

Atomic fixed-window increment with Redis

INCR is atomic in Redis even under concurrent requests from multiple PHP-FPM workers or multiple servers — there is no race condition on the count itself. The EXPIRE is only set when count === 1 so a burst of concurrent first-requests doesn't repeatedly reset the TTL and extend the window forever. TTL is used directly to compute Retry-After, so the client is told exactly how long until the window resets.

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = "rate:{$identifier}:" . floor(time() / $windowSeconds);

$count = $redis->incr($key);
if ($count === 1) {
    // first request in this window: set the expiry once
    $redis->expire($key, $windowSeconds);
}

if ($count > $limit) {
    $ttl = $redis->ttl($key);
    http_response_code(429);
    header('Retry-After: ' . max($ttl, 1));
    exit;
}

Code Examples

This class wraps a fixed-window counter keyed by the current minute (or whatever windowSeconds is) and an identifier string. It returns whether the request is allowed, how many requests remain, and how long to wait if not — which the calling script uses to set the standard rate-limit response headers and, on rejection, a 429 status with Retry-After. Expected result: First 100 requests within the minute: 200 OK with X-RateLimit-Remaining counting down. Request 101: HTTP 429 with Retry-After: <seconds until next minute>.

$count = $this->redis->incr($key);

Common mistake: Forgetting to set an expiry on the key at all means every counter accumulates forever, permanently locking the client out after their first burst — Redis keys without a TTL never reset.

Every request adds its own timestamped entry to a Redis sorted set (ZADD), first purging entries older than the window (ZREMRANGEBYSCORE). ZCARD then gives an exact count of requests within the trailing window — not a fixed calendar bucket — so there's no boundary-burst exploit. Wrapping the operations in a MULTI transaction keeps the purge-and-add atomic against concurrent requests. Expected result: A client sending exactly 100 requests spread evenly across any rolling 60-second span is allowed; request 101 within that same rolling window is rejected, regardless of calendar minute boundaries. The sliding window log uses more memory than fixed window (one sorted-set entry per request in the window), which matters at very high limits — the approximate sliding-window-counter algorithm trades exactness for a smaller memory footprint.

$this->redis->zRemRangeByScore($key, '-inf', (string) $windowStart);

The bucket's current token count and the timestamp it was last checked are stored in a Redis hash. Each call computes how many tokens have refilled since that timestamp (elapsed seconds times the refill rate), caps the total at capacity, and either spends one token (allowed) or rejects and reports how long until the next token is available. This lets an idle client bank up to 20 tokens and burst through them instantly, while a sustained caller is throttled to 5 requests/second on average. Expected result: A client idle for 4+ seconds can immediately fire 20 requests (a burst), then must wait roughly 200ms between subsequent requests to keep earning tokens at 5/sec.

$tokens = min($this->capacity, $tokens + $elapsed * $this->refillRatePerSecond);

Common mistake: Reading and writing the hash as two separate, non-atomic Redis calls (as shown here for clarity) allows a race between two concurrent requests from the same client to both read the same token count and both succeed when only one should — production code should wrap this in a Lua script (EVAL) or a WATCH/MULTI/EXEC transaction for true atomicity.

<?php
declare(strict_types=1);

final class FixedWindowLimiter
{
    public function __construct(
        private readonly Redis $redis,
        private readonly int $limit,
        private readonly int $windowSeconds
    ) {}

    /** @return array{allowed: bool, remaining: int, retryAfter: int} */
    public function check(string $identifier): array
    {
        $bucket = (int) floor(time() / $this->windowSeconds);
        $key = "rl:fixed:{$identifier}:{$bucket}";

        $count = $this->redis->incr($key);
        if ($count === 1) {
            $this->redis->expire($key, $this->windowSeconds);
        }

        $ttl = $this->redis->ttl($key);
        $ttl = $ttl > 0 ? $ttl : $this->windowSeconds;

        return [
            'allowed'    => $count <= $this->limit,
            'remaining'  => max(0, $this->limit - $count),
            'retryAfter' => $ttl,
        ];
    }
}

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$limiter = new FixedWindowLimiter($redis, limit: 100, windowSeconds: 60);
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$result = $limiter->check("ip:{$clientIp}");

header('X-RateLimit-Limit: 100');
header('X-RateLimit-Remaining: ' . $resul
…
<?php
declare(strict_types=1);

final class SlidingWindowLimiter
{
    public function __construct(
        private readonly Redis $redis,
        private readonly int $limit,
        private readonly int $windowSeconds
    ) {}

    public function check(string $identifier): bool
    {
        $key = "rl:sliding:{$identifier}";
        $now = (int) (microtime(true) * 1000); // milliseconds for precision
        $windowStart = $now - ($this->windowSeconds * 1000);

        $this->redis->multi()
            // drop entries older than the window
            ->zRemRangeByScore($key, '-inf', (string) $windowStart)
            // record this attempt (score and member both = now; member made unique below)
            ->zAdd($key, $now, $now . ':' . bin2hex(random_bytes(4)))
            ->zCard($key)
            ->expire($key, $this->windowSeconds)
            ->exec();

        // re-read the count from the transaction result
        $replies = $this->redis->multi()
            ->zCard($key)
            ->exec();

        $count = $replies[0] ?? 0;

        return $count <= $this->limit;
    }
}
<?php
declare(strict_types=1);

final class TokenBucketLimiter
{
    public function __construct(
        private readonly Redis $redis,
        private readonly float $refillRatePerSecond,
        private readonly int $capacity
    ) {}

    /** @return array{allowed: bool, retryAfter: int} */
    public function attempt(string $identifier): array
    {
        $key = "rl:bucket:{$identifier}";
        $now = microtime(true);

        $data = $this->redis->hMGet($key, ['tokens', 'timestamp']);
        $tokens = isset($data['tokens']) ? (float) $data['tokens'] : (float) $this->capacity;
        $lastTs = isset($data['timestamp']) ? (float) $data['timestamp'] : $now;

        // refill based on elapsed time since the last check
        $elapsed = max(0.0, $now - $lastTs);
        $tokens = min($this->capacity, $tokens + $elapsed * $this->refillRatePerSecond);

        if ($tokens < 1.0) {
            $secondsToNextToken = (1.0 - $tokens) / $this->refillRatePerSecond;
            return ['allowed' => false, 'retryAfter' => (int) ceil($secondsToNextToken)];
        }

        $tokens -= 1.0;

        $this->redis->hMSet($key, ['tokens' => $tokens, 'timestamp' => $now]);
        $this-
…

Fixed window vs. token bucket over time

Fixed window can allow near-double the limit across a boundary; token bucket allows a burst up to capacity but then throttles to the steady refill rate.

Fixed window (limit 5, window boundary |):
 |#####     |#####     |     bursts of 2x limit possible at boundary

Token bucket (capacity 5, refill 1/s):
 [#####] -> spend 5 fast -> [    ] -> refills steadily -> [##   ] -> ...
  full        burst           empty      1 token/sec        partial

Important Rules

Never store rate-limit counters in PHP process memory or local files — they must live in shared storage (Redis) so every app server behind the load balancer sees the same count.

Use Redis's atomic operations (INCR, MULTI/EXEC, or a Lua script via EVAL) for the check-and-update step; two non-atomic calls create a race condition that lets clients slip past the limit.

Always set an expiry (EXPIRE/TTL) on rate-limit keys so they self-clean and never leak memory or permanently lock a client out.

Reject over-limit requests with HTTP 429, not 403 — 429 tells the client this is temporary and retryable.

Always include a Retry-After header (in seconds) on a 429 response so well-behaved clients know exactly when to try again.

Choose the identity key deliberately: per-IP for anonymous abuse protection, per-user or per-API-token for fair, tier-aware quota enforcement.

Where this shows up

Every public API you've ever used enforces something like this: GitHub's API returns X-RateLimit-Remaining and 429s once you exhaust your quota; Stripe uses a token-bucket-style limiter to allow short bursts while capping sustained load. On EngiLipi, the code-practice/compiler endpoints (where users submit code to be executed) are prime rate-limiting targets, since each submission consumes real CPU time on a shared execution backend.

// Counting in a static class property class Limiter { private static int $count = 0; public static function hit(): bool { return ++self::$count <= 100; } }

// Counting in shared Redis, visible to every server $count = $redis->incr("rate:{$id}:" . floor(time() / 60));

A static property resets on every PHP-FPM request and is invisible to the other servers behind the load balancer — it provides no real protection at all once you scale beyond a single process.

if ($tooManyRequests) { http_response_code(403); exit('Forbidden'); } if ($tooManyRequests) { http_response_code(429); header('Retry-After: '. $secondsToWait); exit; }

403 signals a permanent authorization failure, giving well-written clients no reason to retry. 429 with Retry-After tells the client exactly how long to back off, which is what rate limiting is meant to communicate.

[Multiple Choice] Why can't a PHP application rely on a static class property to count requests for rate limiting? A. Static properties are too slow to update B. PHP-FPM resets all process memory at the end of each request, and the value would be invisible to other servers behind a load balancer C. PHP does not support static properties D. Static properties can only hold strings

B. PHP-FPM resets all process memory at the end of each request, and the value would be invisible to other servers behind a load balancer — Each PHP request runs in a fresh process/request lifecycle with no memory retained afterward, and in a horizontally scaled deployment, other servers wouldn't see that counter at all — the state must live in shared storage like Redis.

[Multiple Choice] What is the main advantage of a token bucket algorithm over a fixed window? A. It uses less memory than any other algorithm B. It requires no shared storage at all C. It allows a controlled burst of requests while still enforcing a steady average rate, avoiding the fixed window's boundary-doubling exploit D. It never needs a Retry-After header

C. It allows a controlled burst of requests while still enforcing a steady average rate, avoiding the fixed window's boundary-doubling exploit — Token bucket models tokens refilling at a steady rate up to a capacity, so idle clients can bank a burst allowance, while a fixed window can be exploited to send nearly double the intended limit across a boundary.

[True / False] A rate-limited response should return HTTP status 403 Forbidden. A. True B. False

B. False — False. The correct status is 429 Too Many Requests, paired with a Retry-After header, since the block is temporary and the client is expected to retry later.

Extend the TokenBucketLimiter example to support a two-tier limit: free-tier users get capacity 10 with a refill rate of 1 token/second, and pro-tier users get capacity 100 with a refill rate of 10 tokens/second. Write a function that looks up the user's tier and constructs the appropriate limiter before calling attempt().

function limiterForUser(Redis $redis, object $user): TokenBucketLimiter { return match ($user->tier) { 'pro' => new TokenBucketLimiter($redis, refillRatePerSecond: 10.0, capacity: 100), default => new TokenBucketLimiter($redis, refillRatePerSecond: 1.0, capacity: 10), }; } $limiter = limiterForUser($redis, $currentUser); $result = $limiter->attempt("user:{$currentUser->id}");

You don't need two classes — just pass different $capacity and $refillRatePerSecond constructor arguments based on a $user->tier value looked up before the limiter is built.