Building a Video API Rate Limiter With a Sliding Window in Redis

php dev.to

At 18:04 on a Friday our video metadata API slowed to a crawl. Nothing had been deployed. There was no traffic spike on the dashboard and no CPU alarm — just p99 latency climbing from 40ms to almost 3 seconds. When I tailed the access log the cause was obvious: one anonymous client was calling /api/v1/videos/trending roughly 40 times a second, and had been for twenty minutes. It wasn't an attack. It was a scraper someone wrote with no backoff and no cache of its own. But on a free platform like DailyWatch, one greedy client can quietly starve thousands of real viewers, because every one of those calls fans out into a SQLite FTS5 search and, on a cache miss, a full trip past Cloudflare to the origin. We needed a rate limiter — and not a naive one.

Before writing a line of code I wrote down what the limiter actually had to do, because that list eliminates most of the tempting shortcuts:

  • Distributed by default. We run several LiteSpeed workers, and each PHP process handles requests independently. A limiter that counts in local process memory is worthless — a client just gets N× the limit across N workers.
  • Accurate at window boundaries. A limit that lets a burst slip through every minute is not a limit for a trending endpoint. Bursts are the whole problem.
  • Cheap in memory. Millions of anonymous IPs cycle through in a day. Every key has to expire on its own.
  • Fail open. If Redis hiccups, the API must keep serving. A rate limiter that takes the site down when its backing store blips is worse than no limiter at all.

Redis is the obvious shared counter here. The interesting part is the algorithm on top of it.

Why the fixed-window counter isn't enough

The first thing everyone reaches for is a per-minute bucket: INCR rl:ip:1.2.3.4:202607201804, set a 60-second EXPIRE, reject when the counter passes the limit. It is one round trip and trivially correct-looking. It is also wrong in exactly the way that hurt us.

The flaw is the boundary. Suppose the limit is 60 requests per minute. A client sends 60 requests in the last second of minute N, then 60 more in the first second of minute N+1. Each minute bucket is individually within limits, but the client just pushed 120 requests through a two-second window. For a trending endpoint backed by a full-text search, that doubled burst is precisely the load spike you built the limiter to stop. Fixed windows also cause a mild thundering-herd effect: every key in a bucket expires at the same instant, so cleanup and re-creation cluster on the minute boundary.

What we actually want is a window that slides with the clock — always looking at the last 60 seconds, not the last calendar minute. There are two good ways to get that.

The sliding window log

The most accurate approach stores a timestamp for every request in a Redis sorted set, using the timestamp as the score. On each incoming request you do three things:

  1. ZREMRANGEBYSCORE to drop every entry older than now - window.
  2. ZCARD to count what remains — that is the exact number of requests in the true rolling window.
  3. If it's under the limit, ZADD the new request.

This is a real sliding window, not an approximation. At any instant the count reflects exactly the requests in the last 60 seconds, so the boundary burst simply cannot happen. The cost is memory: one sorted-set member per request in the window. For a 60-req/min endpoint that is at most 60 tiny members per active client, which is nothing. For a 10,000-req/min internal endpoint it adds up, and we'll deal with that later.

Making it atomic with Lua

There is a race condition hiding in that three-step sequence. Steps 2 and 3 are check-then-act, and our LiteSpeed workers hit Redis concurrently. Two requests from the same client can both run ZCARD, both read count = limit - 1, both conclude there's room, and both ZADD. Now you're over the limit. Under real burst traffic — which is the only traffic that matters here — this happens constantly.

The clean fix is to make the whole check atomic. Redis executes Lua scripts single-threaded, so a script runs to completion with no other command interleaved. Here is the sliding-window-log check as one script:

-- KEYS[1] = rate-limit key, e.g. rl:v1:ip:203.0.113.7:trending
-- ARGV[1] = current time in microseconds
-- ARGV[2] = window size in microseconds
-- ARGV[3] = max requests allowed in the window
-- ARGV[4] = a unique member id for this request
local key    = KEYS[1]
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit  = tonumber(ARGV[3])
local member = ARGV[4]

-- drop everything older than the window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

local count = redis.call('ZCARD', key)
if count < limit then
    redis.call('ZADD', key, now, member)
    -- expire the key so idle clients release memory automatically
    redis.call('PEXPIRE', key, math.ceil(window / 1000))
    return {1, limit - count - 1, 0}
end

-- rejected: tell the caller when the oldest entry falls out of the window
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = 1
if oldest[2] then
    retry_after = math.ceil((tonumber(oldest[2]) + window - now) / 1000000)
    if retry_after < 1 then retry_after = 1 end
end
return {0, 0, retry_after}
Enter fullscreen mode Exit fullscreen mode

A few details that matter in production. Timestamps are in microseconds, which keeps sorted-set members distinct even when two requests land in the same millisecond — with a coarser clock you'd get score collisions and undercounting. The member is now plus a few random bytes so two genuinely simultaneous requests never overwrite each other's entry. The PEXPIRE on every successful add means an idle client's key evaporates on its own; we never run a sweeper. And the script returns three numbers the caller cares about: allowed (1/0), remaining budget, and how many seconds until it's worth retrying.

Wiring it into a PHP 8.4 service

At the application tier the limiter is a small service that wraps the script. I keep the result as a readonly value object so nothing downstream can mutate it:

<?php
declare(strict_types=1);

final readonly class RateLimitResult
{
    public function __construct(
        public bool $allowed,
        public int $remaining,
        public int $retryAfter,
        public int $limit,
    ) {}
}

final class SlidingWindowLimiter
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $script,   // the Lua source above
        private readonly int $limit = 60,
        private readonly int $windowSeconds = 60,
    ) {}

    public function check(string $identifier): RateLimitResult
    {
        $key    = "rl:v1:{$identifier}";
        $now    = (int) (microtime(true) * 1_000_000);
        $window = $this->windowSeconds * 1_000_000;
        $member = $now . ':' . bin2hex(random_bytes(4));

        $res = $this->redis->eval(
            $this->script,
            [$key, $now, $window, $this->limit, $member],
            1 // number of KEYS
        );

        return new RateLimitResult(
            allowed:    (bool) $res[0],
            remaining:  (int) ($res[1] ?? 0),
            retryAfter: (int) ($res[2] ?? 0),
            limit:      $this->limit,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things I'd flag for anyone copying this. First, the identifier is deliberately compound — ip:203.0.113.7:trending — so limits are per endpoint. Trending gets 60/min; search, which is heavier, gets 30/min; a channel listing gets 120/min. One global limit per client is a blunt instrument that punishes normal browsing. Second, eval ships the whole script on every call. In production you'd SCRIPT LOAD once and call evalSha() with the cached hash so you're only sending 40 bytes of SHA per request. I show eval here because it's self-contained and easy to read.

At the edge of a request handler the limiter turns into a few headers and, when needed, a 429:

$limiter = new SlidingWindowLimiter($redis, $luaScript, limit: 60, windowSeconds: 60);
$result  = $limiter->check('ip:' . $clientIp . ':trending');

header('X-RateLimit-Limit: ' . $result->limit);
header('X-RateLimit-Remaining: ' . $result->remaining);

if (!$result->allowed) {
    header('Retry-After: ' . $result->retryAfter);
    http_response_code(429);
    echo json_encode([
        'error'       => 'rate_limited',
        'retry_after' => $result->retryAfter,
    ]);
    exit;
}
Enter fullscreen mode Exit fullscreen mode

Good clients read Retry-After and back off. The X-RateLimit-* headers let a well-behaved integrator throttle before it ever gets a 429. Returning machine-readable JSON instead of an HTML error page means a client's error handler can act on it. None of this stops a hostile scraper — that's Cloudflare's job at the edge — but it turns the merely careless clients into cooperative ones, and those are the majority.

When the log is too expensive — the sliding window counter

The log is exact but pays for it in memory: one member per request. For a public endpoint capped at 60/min that's fine. For a high-volume internal or authenticated endpoint capped at, say, 10,000/min, you don't want ten thousand sorted-set members per active key. The sliding window counter trades a little accuracy for constant memory: two integer counters per identifier, no matter the limit.

The idea: keep a plain counter per fixed bucket, but when you evaluate the limit, weight the previous bucket by however much of the window still overlaps it. If you're 25% into the current minute, the last 60 seconds is 75% of the previous minute plus all of the current one. So weighted = prev * (1 - elapsed) + cur. Here it is in Python with redis-py:

import time
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

WINDOW = 60   # seconds
LIMIT  = 10000

_counter = r.register_script("""
local key    = KEYS[1]
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit  = tonumber(ARGV[3])

local cur_bucket  = math.floor(now / window)
local prev_bucket = cur_bucket - 1

local cur_key  = key .. ':' .. cur_bucket
local prev_key = key .. ':' .. prev_bucket

local cur  = tonumber(redis.call('GET', cur_key)  or '0')
local prev = tonumber(redis.call('GET', prev_key) or '0')

-- fraction of the way through the current bucket, 0.0 .. 1.0
local elapsed  = (now % window) / window
local weighted = prev * (1 - elapsed) + cur

if weighted >= limit then
    return {0, 0}
end

redis.call('INCR', cur_key)
redis.call('EXPIRE', cur_key, window * 2)
return {1, math.floor(limit - weighted - 1)}
""")


def allow(identifier: str) -> bool:
    allowed, _ = _counter(keys=[f"rl:v1:{identifier}"],
                          args=[int(time.time()), WINDOW, LIMIT])
    return bool(allowed)
Enter fullscreen mode Exit fullscreen mode

The approximation assumes requests in the previous bucket were spread evenly across it, which isn't strictly true, but the error is small and bounded — in practice a fraction of a percent at typical limits, and it never lets the doubled boundary burst through the way a fixed window does. So the rule of thumb we settled on:

  • Sliding window log — when the limit is small and you want exactness. Public, low-cap endpoints.
  • Sliding window counter — when the limit is large or the endpoint is hot and memory matters more than the last decimal of accuracy.

Both run as atomic Lua, both key by the same rl:v1: namespace, so we can flush an entire scheme with one SCAN + delete if we ever need to reset.

A Go version for the edge workers

Most of our API is PHP, but the CDN-adjacent workers that do request shaping are Go, and they share the exact same Lua script. Reusing the script across languages is the payoff of pushing the logic into Redis: the algorithm lives in one place, not three. Here's the log variant as HTTP middleware with go-redis:

package ratelimit

import (
    "context"
    "net/http"
    "strconv"
    "time"

    "github.com/redis/go-redis/v9"
)

var slidingLog = redis.NewScript(`
local key    = KEYS[1]
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit  = tonumber(ARGV[3])
local member = ARGV[4]

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
    redis.call('ZADD', key, now, member)
    redis.call('PEXPIRE', key, math.ceil(window / 1000))
    return 1
end
return 0
`)

type Limiter struct {
    rdb    *redis.Client
    limit  int
    window time.Duration
}

func New(rdb *redis.Client, limit int, window time.Duration) *Limiter {
    return &Limiter{rdb: rdb, limit: limit, window: window}
}

func (l *Limiter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        ctx := context.Background()
        key := "rl:v1:ip:" + clientIP(req) + ":trending"
        now := time.Now().UnixMicro()
        member := strconv.FormatInt(now, 10)

        res, err := slidingLog.Run(ctx, l.rdb, []string{key},
            now, l.window.Microseconds(), l.limit, member).Int()
        if err != nil {
            // fail open: never let a Redis blip take down the API
            next.ServeHTTP(w, req)
            return
        }
        if res == 0 {
            w.Header().Set("Retry-After", "1")
            http.Error(w, "rate limited", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, req)
    })
}
Enter fullscreen mode Exit fullscreen mode

The line that earns its keep is the err != nil branch. If Redis is unreachable, the middleware calls next.ServeHTTP and lets the request through. I have watched teams wire a rate limiter so tightly that a Redis failover turned into a full API outage — every request blocked waiting on a store that wasn't answering. That is a self-inflicted incident. A rate limiter is a protective layer, and a protective layer must degrade to open, never closed.

Operating it in production

The algorithm was the easy part. The lessons that actually kept the API healthy are operational:

  • Fail open, everywhere. Redis timeout, script error, connection refused — serve the request. Alert on the failure, don't block on it.
  • Separate limits per route and per tier. Anonymous IPs get tight limits; requests with a valid API key get much higher ones, keyed by the key instead of the IP. Punishing your best integrators for a scraper's behavior is backwards.
  • Namespace with a version prefix (rl:v1:). When you change the scheme you can expire the old one cleanly without guessing key shapes.
  • Use EVALSHA, not EVAL, once you're past prototyping. Load the script at startup, send the SHA per request, and handle NOSCRIPT by reloading. It shaves real bytes off every call.
  • Derive the numbers from your own p99, not from a blog post. We set the trending limit from the actual request-rate distribution of legitimate sessions, with headroom, then watched the 429 rate. If real users hit it, the limit is wrong.
  • Put a coarse limit at the CDN and the fine one at the app. Cloudflare kills obvious floods at the edge for free; Redis only has to see the subtle, distributed abuse that slips past. Layering keeps Redis load sane.
  • Treat the 429 rate as a product signal. A sudden climb usually means either an integration bug on a partner's side or a limit that's too tight — both worth a look, neither ignorable.

Conclusion

The scraper that started all this now gets a clean 429 with a Retry-After header three requests into its burst, and our p99 went back to 40ms and stayed there. The shape of the solution is simple once the pieces are named: use a sliding window log when you want exact counts at a modest limit, switch to a sliding window counter when memory matters more than the last decimal, wrap either in an atomic Lua script so concurrent workers can't race past the limit, and fail open so the limiter can never become the outage. Redis gives you the shared, expiring, single-threaded store that makes all of it correct across a fleet of workers — the rest is just choosing the right window for each endpoint and watching what your real traffic does with it.

Source: dev.to

arrow_back Back to Tutorials