StackPractices
intermediate By Mathias Paulenko

Implement API Rate Limiting with Redis

Protect APIs from abuse with Redis-backed token bucket and sliding window limiters, including burst handling and distributed coordination

Public APIs attract abuse: scrapers, brute-force attempts, and clients that ignore polite retry behavior. A Redis-backed rate limiter keeps quota state in one place so every node of the API enforces the same limits. This recipe implements the two workhorse algorithms — token bucket and sliding window — with atomic Lua scripts, per-tier configuration, and the response headers clients need to back off correctly. For the security side of throttling (which layer blocks what, how to identify clients), see the Security Best Practices Guide.

When to Use This

  • Public APIs that need protection against brute force and scraping
  • Different user tiers require different rate limits (free vs paid)
  • Multiple API nodes must share rate limit state consistently
  • Webhook or callback endpoints that receive unpredictable bursts from external systems
  • Skip it when a single-instance, low-traffic API is enough: an in-memory limiter avoids the extra network hop to Redis on every request

Solution

1. Token Bucket Algorithm

// rate-limiter/TokenBucket.ts
import { Redis } from 'ioredis';

class TokenBucketLimiter {
  private redis: Redis;

  constructor(redis: Redis) {
    this.redis = redis;
  }

  async check(key: string, capacity: number, refillRate: number): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
    const bucketKey = `ratelimit:token:${key}`;

    const luaScript = `
      local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
      local capacity = tonumber(ARGV[1])
      local refillRate = tonumber(ARGV[2])
      local interval = tonumber(ARGV[3])
      local time = redis.call('TIME')
      local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)

      local tokens = tonumber(bucket[1]) or capacity
      local lastRefill = tonumber(bucket[2]) or now

      local elapsed = now - lastRefill
      local refill = math.floor(elapsed / interval) * refillRate
      tokens = math.min(capacity, tokens + refill)

      if tokens >= 1 then
        tokens = tokens - 1
        redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
        redis.call('EXPIRE', KEYS[1], 3600)
        return {1, tokens, now + interval}
      else
        redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', lastRefill)
        redis.call('EXPIRE', KEYS[1], 3600)
        return {0, tokens, lastRefill + interval}
      end
    `;

    const result = await this.redis.eval(luaScript, 1, bucketKey, capacity, refillRate, 1000);
    const [allowed, remaining, resetTime] = result as [number, number, number];

    return {
      allowed: allowed === 1,
      remaining,
      resetTime,
    };
  }
}

Reading TIME inside the Lua script uses the Redis server clock, so every app node measures elapsed time against the same source, eliminating clock-skew drift between instances.

2. Sliding Window Counter

// rate-limiter/SlidingWindow.ts
class SlidingWindowLimiter {
  private redis: Redis;

  constructor(redis: Redis) {
    this.redis = redis;
  }

  async check(key: string, limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
    const now = Date.now();
    const windowKey = `ratelimit:window:${key}`;
    const windowStart = now - windowMs;

    const pipeline = this.redis.pipeline();
    pipeline.zremrangebyscore(windowKey, 0, windowStart);
    pipeline.zcard(windowKey);
    pipeline.zadd(windowKey, now, `${now}-${Math.random()}`);
    pipeline.pexpire(windowKey, windowMs);

    const results = await pipeline.exec();
    // exec() returns [err, result] tuples: index 1 holds the ZCARD count
    const currentCount = (results?.[1]?.[1] ?? 0) as number;

    if (currentCount < limit) {
      return {
        allowed: true,
        remaining: limit - currentCount - 1,
        resetTime: now + windowMs,
      };
    }

    // Remove the request we just added
    await this.redis.zremrangebyrank(windowKey, -1, -1);

    const oldest = await this.redis.zrange(windowKey, 0, 0, 'WITHSCORES');
    const resetTime = oldest.length > 0 ? Number(oldest[1]) + windowMs : now + windowMs;

    return {
      allowed: false,
      remaining: 0,
      resetTime,
    };
  }
}

3. Express Middleware with Headers

// middleware/rateLimit.ts
import { Request, Response, NextFunction } from 'express';

function rateLimit(limiter: TokenBucketLimiter, options: { capacity: number; refillRate: number }) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = `${req.ip}:${req.route?.path || req.path}`;
    const result = await limiter.check(key, options.capacity, options.refillRate);

    res.setHeader('RateLimit-Limit', String(options.capacity));
    res.setHeader('RateLimit-Remaining', String(Math.max(0, result.remaining)));
    res.setHeader('RateLimit-Reset', String(Math.ceil(result.resetTime / 1000)));

    if (!result.allowed) {
      res.setHeader('Retry-After', String(Math.ceil((result.resetTime - Date.now()) / 1000)));
      res.status(429).json({
        error: 'Too Many Requests',
        retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000),
      });
      return;
    }

    next();
  };
}

// Usage
app.use('/api/public', rateLimit(limiter, { capacity: 100, refillRate: 10 }));
app.use('/api/premium', rateLimit(limiter, { capacity: 1000, refillRate: 100 }));

The RateLimit-* header names follow the IETF draft for rate limit fields; the older X-RateLimit-* convention still works but the unprefixed form is what newer clients and gateways parse.

4. Per-User Tier Configuration

// rate-limiter/TierConfig.ts
interface RateLimitConfig {
  capacity: number;
  refillRate: number;
}

const tierLimits: Record<string, RateLimitConfig> = {
  free: { capacity: 100, refillRate: 10 },
  pro: { capacity: 1000, refillRate: 100 },
  enterprise: { capacity: 10000, refillRate: 1000 },
};

async function getUserTier(userId: string): Promise<string> {
  // Fetch from database or cache
  return 'pro';
}

// Middleware with tier lookup
async function tieredRateLimit(req: Request, res: Response, next: NextFunction) {
  const userId = req.headers['x-user-id'] as string;
  const tier = userId ? await getUserTier(userId) : 'free';
  const config = tierLimits[tier] || tierLimits.free;

  const key = `${tier}:${userId || req.ip}`;
  const result = await limiter.check(key, config.capacity, config.refillRate);

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

  next();
}

Python Variant (redis-py)

The same Lua script runs from Python through register_script, which caches it server-side and sends EVALSHA on subsequent calls. Time still comes from redis.call('TIME'), so every app node measures against the same clock:

# rate_limiter.py (pip install redis)
import redis

TOKEN_BUCKET_LUA = """
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[1])
local last   = tonumber(redis.call('HGET', KEYS[1], 'last') or '0')
local t      = redis.call('TIME')
local now    = t[1] + t[2] / 1000000

tokens = math.min(tonumber(ARGV[1]), tokens + (now - last) * tonumber(ARGV[2]))
local allowed = tokens >= 1
if allowed then tokens = tokens - 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'last', now)
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return allowed and 1 or 0
"""

def make_limiter(client: redis.Redis):
    script = client.register_script(TOKEN_BUCKET_LUA)

    def check(key: str, capacity: int, refill_rate: float) -> bool:
        ttl_ms = int(capacity / refill_rate * 2000)
        return bool(script(keys=[f"rate:{key}"], args=[capacity, refill_rate, ttl_ms]))

    return check

For a batteries-included Python implementation, see Python API Rate Limiting with Token Bucket.

How It Works

  • Token bucket allows bursts up to capacity while holding the average rate
  • Sliding window gives stricter limits by tracking exact request timestamps
  • Redis Lua scripts make the check-and-decrement atomic across distributed nodes
  • Response headers tell clients their remaining quota and when the window resets
flowchart diagram: Client request

Choosing an Algorithm

The four common windowing strategies differ in memory cost and strictness:

AlgorithmMemory per clientAccuracyBest for
Token bucketO(1) hash fieldsAverage rate; allows bursts up to capacityMost APIs — steady rate with controlled bursts
Fixed window counterO(1), one INCR keyBoundary flaw: up to 2x limit at window edgesLow-precision limits where simplicity wins
Sliding window counterO(1), two countersApproximate (weighted previous window)Better accuracy than fixed window at O(1) cost
Sliding window logO(n), one entry per requestExact count in any rolling windowCompliance APIs that must enforce exact counts

The sliding window log stores a sorted-set entry per request (ZADD rate:log:{key} <ts> <requestId>, prune with ZREMRANGEBYSCORE, count with ZCARD), so memory grows with traffic; prefer token bucket for high-volume endpoints. Without Redis, the same token bucket can run on PostgreSQL SELECT FOR UPDATE or DynamoDB conditional writes, at the cost of a database round trip per check; CDN-level rules (Cloudflare rate limiting, AWS WAF rate-based rules) can also block abuse before it reaches the server.

Production Considerations

  • Use Redis Cluster for high-availability rate limit state; hash tags like rate:{tenantId}:… keep a client’s keys on the same slot so multi-key Lua operations stay atomic
  • Implement a circuit breaker around Redis to fail open if the cache goes down
  • Log rate limit violations for security monitoring and abuse detection

Common Mistakes

  • Rate limiting only by IP, which blocks legitimate users behind NAT: combine IP with API key or user ID when available
  • Not handling Redis failures gracefully, causing API outages when the cache is slow or unreachable
  • Returning 429 without Retry-After, leaving clients guessing when to retry
  • Doing SET + EXPIRE as separate calls; without Lua or MULTI the check-and-decrement isn’t atomic and concurrent requests overshoot the limit
  • Forgetting a TTL on rate limit keys, so inactive clients keep keys alive forever and Redis memory grows without bound

Troubleshooting

  • Every client gets 429 right after a deploy: check whether the key prefix or hash-tag layout changed: a new prefix restarts counters and makes healthy clients look throttled
  • Users behind the same corporate proxy get throttled together: you’re keying on IP; switch to API key or user ID, or raise limits on NAT-heavy endpoints
  • p99 latency spikes when Redis is slow: set a command timeout (~50ms) and fail open; inspect slow EVALSHA calls with SLOWLOG GET
  • Rate limit keys never expire: audit with SCAN + TTL: every mutating command in the script must also set EXPIRE/PEXPIRE
  • Inconsistent limits across nodes: app-server clocks drift; read redis.call('TIME') inside the Lua script instead of passing Date.now() from each instance

Further Reading

Frequently Asked Questions

Token bucket vs sliding window: which should I use?

Token bucket allows controlled bursts and is cheaper on Redis (one Lua call per request). Sliding window is stricter and better when you must enforce exact counts in a rolling window — think financial APIs with compliance requirements. For most APIs, token bucket with a burst size of 2x the steady-state rate is enough.

What happens when Redis goes down — does the API break?

Only if you fail closed. Wrap Redis calls in a circuit breaker: when Redis is unavailable, fail open (allow the request) and log a warning: failing closed turns a cache outage into a full API outage. For short outages, fall back to an in-memory counter like lru-cache. Set a ~50ms command timeout so a slow Redis can't stall request processing, and alert while the breaker is open.

How do I set per-tier rate limits (free, pro, enterprise)?

Carry the tier in the API key or JWT claims and key the bucket per tier: rate:free:{key}, rate:pro:{key}, rate:enterprise:{key}, each with its own capacity and refill rate. Return the tier in a response header (RateLimit-Tier: pro) so clients can debug their limits. Read tier config from a database or config service so you can change limits without redeploying.

How do I handle rate limits across microservices?

Share one Redis instance (or a dedicated rate limiting service) and let each service call the limiter before processing. For service-to-service calls, give each service pair its own bucket so one downstream can't exhaust another's quota. An API gateway like Kong or Envoy can enforce limits centrally instead. On Redis Cluster, use hash tags so each client's keys land on the same slot.

Can I rate limit by something other than IP?

Yes — and usually you should. Key on API key (rate:apikey:{sha256(apiKey)}), user ID for authenticated traffic, or a tenant namespace for multi-tenant APIs (rate:tenant:{tenantId}:{key}). A common split: rate:ip:{ip} at 10 req/min for anonymous requests and rate:user:{userId} at 100 req/min for authenticated ones. Hash API keys before using them as Redis keys.

What headers should I return on a 429?

At minimum Retry-After (seconds until the client can retry). For normal responses return RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset (Unix timestamp) per the IETF draft, or the legacy X-RateLimit-* equivalents if your clients already parse those. Don't expose internal configuration like bucket size or refill rate.

How do I rate limit WebSocket connections?

Limit by connection ID, not IP. Track active connections per user in a sorted set (ZADD ws:connections <ts> <connectionId>), cap connections per user, and count messages per connection with a token bucket at rate:ws:{connectionId}. Close abusive clients with WebSocket close code 1008 (policy violation) and clean up entries on disconnect with ZREM.

How do I rate limit GraphQL APIs?

Rate limit by query cost, not request count: a single query can cost as much as 100 REST calls. Score fields by complexity, compute the total before execution (the graphql-cost-analysis package does this), and accumulate the cost in Redis with INCRBY rate:gql:{userId} <cost>. Persisted queries let you cache the computed cost. Return RateLimit-Cost headers so clients see what each query spends.

How do I test rate limiting logic?

Unit-test the Lua script directly with redis-cli --eval against mock keys, covering the boundary cases: exactly at the limit, one over, and refill after waiting. For middleware tests, use ioredis-mock or a real Redis in a testcontainers container, fire N rapid requests, and assert that request N+1 returns 429 with a Retry-After header. Verify atomicity under Promise.all concurrency, and run k6 or artillery to check the limiter holds up at expected throughput.

How do I monitor and alert on rate limiting?

Track four numbers: total requests, 429 responses, top throttled clients, and Redis command latency. Alert when 429s pass ~5% of traffic: either the limits are too tight or a client is misbehaving. Watch EVALSHA latency; anything past ~10ms means the script or Redis needs attention. Alert when the circuit breaker opens, because that's when rate limiting silently turns off.