DEV Community

Cover image for Rate Limiting: The Sliding Window Approach
Aniket Bhatia
Aniket Bhatia

Posted on

Rate Limiting: The Sliding Window Approach

Rate Limiting desc

Every web application faces a fundamental challenge: how do you let legitimate users interact freely while preventing abuse? Without rate limiting, your endpoints are open to brute force attacks, denial-of-service flooding, scraping bots, and resource exhaustion that drives up infrastructure costs.

Rate limiting is the gatekeeper. Here's how to build one that actually works.


Common approaches

1. Fixed window counter

The simplest approach. Divide time into fixed intervals (e.g., every calendar minute) and count requests in each. It's easy to implement, but it has a critical flaw: the boundary exploit.

FWC img

2. Sliding log (precise but expensive)

Store the timestamp of every request. On each new request, count how many timestamps fall within the last N seconds. Perfectly accurate, but storing every timestamp is memory-intensive at scale.

3. Sliding window (best of both worlds)

Uses a sorted set to track request timestamps, continuously sliding the window forward. Old entries are pruned on every check. No boundary exploits, and memory stays bounded. This is the approach worth building.


How the sliding window works

Instead of resetting a counter at fixed intervals, maintain a sorted set where each member is a unique request ID and its score is the Unix timestamp. On every incoming request:

  1. Prune — remove all entries older than now - window_size
  2. Count — count remaining entries
  3. Decide — if count ≥ limit, deny; otherwise add the new request and allow

How SW works?


Implementation: Redis + Lua

Redis sorted sets are ideal for this pattern. A Lua script ensures atomicity — no race conditions when multiple requests arrive simultaneously. Without atomicity, two concurrent requests could both see count=59 and both be allowed past a limit of 60.

-- Step 1: Prune expired entries from the main window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- Step 2: Count remaining requests
local current = redis.call('ZCARD', key)
if current >= limit then
    return {0, current, limit}  -- DENIED
end

-- Step 3: Check burst window
redis.call('ZREMRANGEBYSCORE', burst_key, 0, now - burst_window)
local burst_current = redis.call('ZCARD', burst_key)
if burst_current >= burst_limit then
    return {-1, burst_current, burst_limit}  -- DENIED: burst
end

-- Step 4: Record the request in both windows
local member = tostring(now) .. ':' .. tostring(math.random(1000000))
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window + 1)
redis.call('ZADD', burst_key, now, member)
redis.call('EXPIRE', burst_key, burst_window + 1)

return {1, current + 1, limit}  -- ALLOWED
Enter fullscreen mode Exit fullscreen mode

Why a random suffix on members? Sorted sets require unique members. Two requests arriving at the exact same millisecond would overwrite each other without it, causing undercounting.

Dual-layer protection

A single window limit has a blind spot: a client could exhaust all 60 requests in the first 2 seconds and go silent for 58. The average rate looks fine, but the spike hammers your server. The solution is two checks that must both pass.

Layer Window Limit Prevents
Window 60 seconds 60 requests Sustained abuse across time boundaries
Burst 5 seconds 10 requests Rapid-fire spikes (bots hammering in 1s)

FastAPI middleware integration

The rate limiter works best as middleware — intercepting every request before it reaches application logic.

@app.middleware('http')
async def rateLimitMiddleware(request, call_next):
    # Skip bypass paths (health checks, login)
    if request.url.path in bypass_paths:
        return await call_next(request)

    # Key by user (authenticated) or IP (anonymous)
    user = get_authenticated_user(request)
    rate_key = f"ratelimit:user:{user}" if user else f"ratelimit:ip:{ip}"

    result = await redis.eval(LUA_SCRIPT, keys=[rate_key, burst_key], args=[...])

    if result[0] == 0:
        return JSONResponse({"error": "Rate limit exceeded."},
            status_code=429, headers={"Retry-After": "60"})
    if result[0] == -1:
        return JSONResponse({"error": "Too many requests. Slow down."},
            status_code=429, headers={"Retry-After": "5"})

    response = await call_next(request)
    response.headers["X-RateLimit-Remaining"] = str(limit - result[1])
    return response
Enter fullscreen mode Exit fullscreen mode
Decision Rationale
Per-user key (authenticated) Prevents one user from affecting others
Per-IP fallback (unauthenticated) Protects public endpoints from anonymous abuse
Fail-open on Redis errors Avoids blocking legitimate traffic if Redis goes down
HTTP 429 + Retry-After Standard; well-handled by clients and load balancers
X-RateLimit-* headers Gives clients visibility into their remaining quota

Testing

A quick smoke test to verify your burst limit fires correctly:

# Send 15 rapid requests — expect 429 after the 10th
for i in $(seq 1 15); do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Cookie: session=YOUR_COOKIE" https://your-app/api/me)
  echo "Request $i: HTTP $STATUS"
done

# Inspect the sorted set directly
redis-cli ZCARD ratelimit:user:alice@example.com
redis-cli ZRANGE ratelimit:user:alice@example.com 0 -1 WITHSCORES
Enter fullscreen mode Exit fullscreen mode

Summary

Fixed window

Simple but vulnerable to boundary exploits. Low memory, approximate accuracy.

Sliding log

Exact accuracy, no boundary issues. High memory cost at scale.

Sliding window ✓

Exact accuracy, no boundary exploits, moderate memory. The right choice.

Rate limiting isn't just about counting requests — it's about counting them in the right window.

Top comments (0)