Programming

API Rate Limiting: Implementation Guide with Redis, Token Bucket, and Sliding Window

2026-07-19·13 min read
#API#rate limiting#Redis#backend#security

API Rate Limiting: Implementation Guide with Redis, Token Bucket, and Sliding Window

Rate limiting is the traffic cop of your API. Without it, a single misbehaving client can take down your entire service. With it, you ensure fair usage, protect backend resources, and provide a predictable experience for all users.

This guide covers the four major rate limiting algorithms, shows how to implement each one with Redis, and helps you choose the right strategy for your use case.

Why Rate Limiting Matters

Without rate limiting, your API is vulnerable to:

  • Abuse: A single user making 10,000 requests/second can exhaust database connections, memory, and CPU
  • Accidental DDoS: A client bug causing retry storms can take down production
  • Resource starvation: One heavy API consumer starves legitimate users
  • Cost explosion: Cloud costs scale with requests — uncontrolled traffic means uncontrolled bills
  • Security: Brute-force attacks on login, password reset, and OTP endpoints are impossible without rate limits

With rate limiting:

  • APIs stay responsive under load
  • Fair usage is enforced (freemium vs paid tiers)
  • Security is improved (brute-force protection)
  • Costs are predictable

The Four Algorithms

1. Fixed Window Counter

How it works: Divide time into fixed windows (e.g., 1-minute blocks). Count requests per window. Reset the counter at the window boundary.

Window: [00:00 - 00:59] → max 100 requests
Window: [01:00 - 01:59] → max 100 requests

Implementation (Redis):

import redis
import time

r = redis.Redis()

def fixed_window(key, limit=100, window=60):
    now = int(time.time())
    window_key = f"rate:{key}:{now // window}"
    
    pipe = r.pipeline()
    pipe.incr(window_key)
    pipe.expire(window_key, window)
    count, _ = pipe.execute()
    
    if count > limit:
        return False  # Rate limited
    return True  # Allowed

Pros: Simple, memory-efficient. Cons: Burst at boundaries — a client can make 100 requests at 00:59 and another 100 at 01:00, effectively sending 200 requests in one second.

2. Sliding Window Log

How it works: Store timestamps of every request. When a new request arrives, remove timestamps older than the window and count the remaining ones.

Implementation (Redis Sorted Set):

def sliding_window_log(key, limit=100, window=60):
    now = time.time()
    window_start = now - window
    
    pipe = r.pipeline()
    # Remove old entries
    pipe.zremrangebyscore(key, 0, window_start)
    # Count current entries
    pipe.zcard(key)
    # Add the current request
    pipe.zadd(key, {str(now): now})
    # Set expiration for cleanup
    pipe.expire(key, window)
    _, count, _, _ = pipe.execute()
    
    if count >= limit:
        return False
    return True

Pros: Precise — no boundary bursts. Cons: Memory-heavy — stores every request timestamp.

3. Sliding Window Counter (Recommended)

How it works: Combines fixed window with sliding estimation. Uses the current window's count and the previous window's count, weighted by how far into the current window we are.

def sliding_window_counter(key, limit=100, window=60):
    now = time.time()
    current_window = int(now // window)
    prev_window = current_window - 1
    
    current_key = f"rate:{key}:{current_window}"
    prev_key = f"rate:{key}:{prev_window}"
    
    pipe = r.pipeline()
    pipe.get(current_key)
    pipe.get(prev_key)
    pipe.incr(current_key)
    pipe.expire(current_key, window * 2)
    curr_count, prev_count, _, _ = pipe.execute()
    
    curr_count = int(curr_count or 0)
    prev_count = int(prev_count or 0)
    
    # Weighted estimate
    elapsed = now - (current_window * window)
    weight = 1 - (elapsed / window)
    estimated = curr_count + (prev_count * weight)
    
    if estimated > limit:
        return False
    return True

Pros: Smooth, memory-efficient, no boundary bursts. Cons: Slightly approximate (within ~5% of actual).

4. Token Bucket (Recommended for APIs)

How it works: Tokens are added to a bucket at a fixed rate (e.g., 10 tokens/second). Each request consumes one token. If the bucket is empty, the request is denied. The bucket has a maximum capacity — allowing short bursts.

Capacity: 100 tokens
Refill rate: 10 tokens/second

→ Allows a burst of 100 requests instantly
→ Then sustained 10 req/s

Implementation (Redis + Lua for atomicity):

-- token_bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])  -- tokens per second
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- Refill tokens based on elapsed time
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + (elapsed * refill_rate))

local allowed = tokens >= requested
if allowed then
    tokens = tokens - requested
end

redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, math.ceil(capacity / refill_rate) * 2)

if allowed then
    return 1
else
    return 0
end

Node.js implementation:

const redis = require('redis');
const fs = require('fs');
const path = require('path');

const client = redis.createClient({ url: 'redis://localhost:6379' });
await client.connect();

// Load Lua script
const luaScript = fs.readFileSync(path.join(__dirname, 'token_bucket.lua'), 'utf8');
const tokenBucket = client.defineCommand('tokenBucket', {
  number_of_keys: 1,
  lua: luaScript,
});

async function rateLimit(userId, options = {}) {
  const {
    capacity = 100,
    refillRate = 10,     // tokens per second
    requested = 1,       // tokens per request
  } = options;

  const now = Date.now() / 1000;
  const key = `ratelimit:${userId}`;

  const allowed = await client.tokenBucket(key, capacity, refillRate, now, requested);
  return allowed === 1;
}

// Express middleware
app.use('/api', async (req, res, next) => {
  const userId = req.user?.id || req.ip;
  const allowed = await rateLimit(userId, { capacity: 100, refillRate: 10 });

  if (!allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded' });
  }
  next();
});

Pros: Allows bursts (realistic for web traffic), smooth, memory-efficient. Cons: Slightly more complex implementation.

Choosing the Right Algorithm

| Algorithm | Best For | Burst Tolerance | Memory | Accuracy | |-----------|----------|-----------------|--------|----------| | Fixed Window | Simple APIs, low traffic | Poor at boundaries | Low | Medium | | Sliding Window Log | Precise control, low RPS | Good | High | Exact | | Sliding Window Counter | Most APIs | Good | Low | ~95% | | Token Bucket | APIs with bursty traffic | Excellent | Low | Smooth |

Recommendation for most APIs: Token Bucket or Sliding Window Counter.

Distributed Rate Limiting

For multi-server deployments, rate limiting must be coordinated. Redis is the standard choice because it's fast, atomic, and widely supported.

Why Atomicity Matters

This code has a race condition:

# WRONG — race condition
count = r.get(key) or 0
if count >= limit:
    return False
r.incr(key)
return True

Two requests can read count = 99 simultaneously, both pass the check, and both increment — allowing 101 requests when only 100 should be permitted.

Atomic Solutions

Option 1: Redis Lua Script (recommended) The Lua scripts above run atomically — no race conditions.

Option 2: Redis Transactions

pipe = r.pipeline()
pipe.multi()
pipe.incr(key)
pipe.expire(key, window)
results = pipe.execute()
count = results[0]

Option 3: Sorted Set with Lua

# Atomic sliding window using Lua
LUA_SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
    return 0
end
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
"""

Rate Limit Headers

Communicate rate limits to clients via HTTP headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1721419200
Retry-After: 30

Express middleware with headers:

function rateLimitMiddleware(options) {
  return async (req, res, next) => {
    const userId = req.user?.id || req.ip;
    const allowed = await checkRateLimit(userId, options);

    res.setHeader('X-RateLimit-Limit', options.capacity);
    res.setHeader('X-RateLimit-Remaining', allowed.remaining);
    res.setHeader('X-RateLimit-Reset', allowed.resetAt);

    if (!allowed.ok) {
      res.setHeader('Retry-After', allowed.retryAfter);
      return res.status(429).json({
        error: 'Too Many Requests',
        retry_after: allowed.retryAfter,
      });
    }
    next();
  };
}

Tiered Rate Limiting

Different users get different limits:

function getLimitsForUser(user) {
  if (!user) return { capacity: 20, refillRate: 2 };      // Anonymous
  if (user.tier === 'free') return { capacity: 100, refillRate: 10 };  // Free
  if (user.tier === 'pro') return { capacity: 1000, refillRate: 50 };  // Pro
  if (user.tier === 'enterprise') return { capacity: 10000, refillRate: 100 }; // Enterprise
  return { capacity: 100, refillRate: 10 };
}

app.use('/api', async (req, res, next) => {
  const limits = getLimitsForUser(req.user);
  // Apply rate limit with limits
});

Per-Endpoint Rate Limiting

Different endpoints need different limits:

// Login: strict (prevent brute force)
app.post('/login', rateLimitMiddleware({ capacity: 5, refillRate: 0.1 }), loginHandler);

// Search: moderate
app.get('/search', rateLimitMiddleware({ capacity: 30, refillRate: 5 }), searchHandler);

// General API: standard
app.use('/api', rateLimitMiddleware({ capacity: 100, refillRate: 10 }));

// Upload: lower
app.post('/upload', rateLimitMiddleware({ capacity: 10, refillRate: 1 }), uploadHandler);

Monitoring and Observability

Track rate limiting metrics:

# Prometheus metrics
from prometheus_client import Counter, Histogram

rate_limited_total = Counter('api_rate_limited_total', 'Requests rate limited', ['endpoint', 'user_tier'])
rate_limit_check_duration = Histogram('api_rate_limit_check_duration_seconds', 'Rate limit check latency')

@rate_limit_check_duration.time()
def check_rate_limit(user_id, endpoint):
    allowed = token_bucket_check(user_id, endpoint)
    if not allowed:
        rate_limited_total.labels(endpoint=endpoint, user_tier=user.tier).inc()
    return allowed

Key metrics to watch:

  • Rate-limited request rate: How many requests are being blocked?
  • Rate limit check latency: Should be <5ms with Redis
  • Redis memory usage: Sorted sets can grow if not cleaned up
  • 429 response rate per endpoint: Unusual spikes indicate abuse or bugs

Common Mistakes

  1. Using client IP as the only key — users behind corporate NAT share an IP. Use authenticated user ID when available.
  2. Not handling Redis failures — if Redis goes down, decide: fail open (allow traffic) or fail closed (deny all). Most APIs fail open.
  3. Not cleaning up old keys — always set TTL/expiration on rate limit keys.
  4. Rate limiting on the wrong layer — do it at the application/API gateway layer, not at the load balancer (unless using a specialized rate limiting proxy).
  5. Ignoring 429 responses — clients should implement exponential backoff with jitter. Document your rate limit headers so they can.

Conclusion

Rate limiting is infrastructure, not an afterthought. For most APIs:

  1. Use Token Bucket — it handles bursts gracefully
  2. Use Redis with Lua scripts — atomic, fast, distributed
  3. Communicate via headersX-RateLimit-* and Retry-After
  4. Tier by user type — free vs pro vs enterprise
  5. Monitor everything — blocked requests tell you about abuse and bugs

A well-implemented rate limiter protects your service, your users, and your cloud bill.