Skip to content

API Rate Limiting: Algorithms and Implementation

A deep dive into token bucket, sliding window and leaky bucket rate limiting, with distributed implementations in Redis and practical middleware patterns.

4 min read
Diagram comparing token bucket, sliding window, and leaky bucket rate limiting algorithms

Why Rate Limiting Is Non-Negotiable

Every public API needs rate limiting. Without it, a single misbehaving client—whether malicious or buggy—can consume all available server resources, degrading the experience for every other user. Rate limiting protects your infrastructure, ensures fair access, and provides predictable behavior under load.

The challenge is choosing the right algorithm for your use case. Different algorithms have different trade-offs in burst handling, memory usage, and fairness.

Token Bucket Algorithm

The token bucket is the most common rate limiting algorithm. Tokens are added to a bucket at a fixed rate. Each request consumes a token. If the bucket is empty, the request is rejected. The bucket has a maximum capacity, allowing controlled bursts.

tstypescript
class TokenBucket {
  private tokens: number;
  private lastRefill: number;
 
  constructor(
    private readonly capacity: number,
    private readonly refillRate: number // tokens per second
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }
 
  tryConsume(tokens: number = 1): boolean {
    this.refill();
 
    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return true;
    }
 
    return false;
  }
 
  private refill(): void {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    const newTokens = elapsed * this.refillRate;
 
    this.tokens = Math.min(this.capacity, this.tokens + newTokens);
    this.lastRefill = now;
  }
 
  getState(): { tokens: number; capacity: number } {
    this.refill();
    return {
      tokens: Math.floor(this.tokens),
      capacity: this.capacity,
    };
  }
}
 
// Usage
const bucket = new TokenBucket(100, 10); // 100 capacity, 10 tokens/sec
if (bucket.tryConsume()) {
  // Process request
} else {
  // Return 429 Too Many Requests
}

The token bucket allows bursts up to the bucket capacity while maintaining a steady long-term rate. This is ideal for APIs where users naturally make requests in bursts—loading a dashboard that fires ten API calls simultaneously.

Sliding Window Counter

The sliding window counter provides smoother rate limiting without the burst allowance of the token bucket. It tracks request counts in small time windows and interpolates between them.

tstypescript
// ❌ Fixed window — allows 2x burst at window boundaries
// At 11:59:59 user sends 100 requests (resets at 12:00:00)
// At 12:00:01 user sends another 100 requests
// Result: 200 requests in 2 seconds despite 100/minute limit
 
// ✅ Sliding window — smooth rate limiting across boundaries
class SlidingWindowCounter {
  private windows: Map<string, number> = new Map();
  private readonly windowSize: number; // in milliseconds
 
  constructor(
    private readonly maxRequests: number,
    private readonly windowMs: number
  ) {
    this.windowSize = windowMs;
  }
 
  tryConsume(clientId: string): { allowed: boolean; remaining: number; resetMs: number } {
    const now = Date.now();
    const currentWindow = Math.floor(now / this.windowSize);
    const previousWindow = currentWindow - 1;
 
    const currentKey = `${clientId}:${currentWindow}`;
    const previousKey = `${clientId}:${previousWindow}`;
 
    const currentCount = this.windows.get(currentKey) || 0;
    const previousCount = this.windows.get(previousKey) || 0;
 
    // Weight the previous window by how much of it overlaps
    const elapsedInWindow = (now % this.windowSize) / this.windowSize;
    const weightedCount =
      previousCount * (1 - elapsedInWindow) + currentCount;
 
    if (weightedCount >= this.maxRequests) {
      const resetMs = this.windowSize - (now % this.windowSize);
      return {
        allowed: false,
        remaining: 0,
        resetMs,
      };
    }
 
    this.windows.set(currentKey, currentCount + 1);
    this.cleanup(currentWindow);
 
    return {
      allowed: true,
      remaining: Math.floor(this.maxRequests - weightedCount - 1),
      resetMs: this.windowSize - (now % this.windowSize),
    };
  }
 
  private cleanup(currentWindow: number): void {
    for (const key of this.windows.keys()) {
      const windowNum = parseInt(key.split(":")[1], 10);
      if (windowNum < currentWindow - 1) {
        this.windows.delete(key);
      }
    }
  }
}

Distributed Rate Limiting with Redis

In-memory rate limiters fail in distributed systems because each server maintains its own count. Redis provides atomic operations for shared state across all instances.

tstypescript
import { Redis } from "ioredis";
 
class RedisRateLimiter {
  constructor(private readonly redis: Redis) {}
 
  async slidingWindowLimit(
    key: string,
    maxRequests: number,
    windowSeconds: number
  ): Promise<{ allowed: boolean; remaining: number; retryAfter: number }> {
    const now = Date.now();
    const windowMs = windowSeconds * 1000;
    const windowStart = now - windowMs;
 
    const pipeline = this.redis.pipeline();
 
    // Remove expired entries
    pipeline.zremrangebyscore(key, "-inf", windowStart);
    // Add current request
    pipeline.zadd(key, now, `${now}:${Math.random()}`);
    // Count requests in window
    pipeline.zcard(key);
    // Set TTL to auto-cleanup
    pipeline.expire(key, windowSeconds + 1);
 
    const results = await pipeline.exec();
    const requestCount = results?.[2]?.[1] as number;
 
    if (requestCount > maxRequests) {
      // Remove the request we just added
      await this.redis.zremrangebyscore(key, now, now);
 
      // Calculate retry-after from oldest request in window
      const oldest = await this.redis.zrange(key, 0, 0, "WITHSCORES");
      const oldestTime = oldest.length > 1 ? parseInt(oldest[1], 10) : now;
      const retryAfterMs = oldestTime + windowMs - now;
 
      return {
        allowed: false,
        remaining: 0,
        retryAfter: Math.ceil(retryAfterMs / 1000),
      };
    }
 
    return {
      allowed: true,
      remaining: maxRequests - requestCount,
      retryAfter: 0,
    };
  }
}

Express Middleware Implementation

Wrap the rate limiter in middleware that handles response headers, error responses, and client identification.

tstypescript
import { Request, Response, NextFunction } from "express";
 
interface RateLimitConfig {
  maxRequests: number;
  windowSeconds: number;
  keyGenerator: (req: Request) => string;
  skip?: (req: Request) => boolean;
  onLimitReached?: (req: Request) => void;
}
 
function createRateLimitMiddleware(
  limiter: RedisRateLimiter,
  config: RateLimitConfig
) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ): Promise<void> => {
    if (config.skip?.(req)) {
      next();
      return;
    }
 
    const key = `ratelimit:${config.keyGenerator(req)}`;
    const result = await limiter.slidingWindowLimit(
      key,
      config.maxRequests,
      config.windowSeconds
    );
 
    // Always set rate limit headers
    res.setHeader("X-RateLimit-Limit", config.maxRequests);
    res.setHeader("X-RateLimit-Remaining", result.remaining);
    res.setHeader(
      "X-RateLimit-Reset",
      Math.ceil(Date.now() / 1000) + config.windowSeconds
    );
 
    if (!result.allowed) {
      res.setHeader("Retry-After", result.retryAfter);
      config.onLimitReached?.(req);
 
      res.status(429).json({
        error: "Too Many Requests",
        message: `Rate limit exceeded. Try again in ${result.retryAfter} seconds.`,
        retryAfter: result.retryAfter,
      });
      return;
    }
 
    next();
  };
}
 
// Usage with different tiers
const apiLimiter = createRateLimitMiddleware(limiter, {
  maxRequests: 100,
  windowSeconds: 60,
  keyGenerator: (req) => req.headers["x-api-key"] as string || req.ip || "unknown",
  skip: (req) => req.path === "/health",
  onLimitReached: (req) => {
    console.warn(`Rate limit hit: ${req.ip} on ${req.path}`);
  },
});
 
const authLimiter = createRateLimitMiddleware(limiter, {
  maxRequests: 5,
  windowSeconds: 300,
  keyGenerator: (req) => `auth:${req.ip}`,
});

Tiered Rate Limiting

Different API consumers deserve different limits. Free tier users get lower limits; premium users get higher limits; internal services need even higher or unlimited access.

tstypescript
interface RateLimitTier {
  name: string;
  requestsPerMinute: number;
  requestsPerDay: number;
  burstCapacity: number;
}
 
const tiers: Record<string, RateLimitTier> = {
  free: {
    name: "Free",
    requestsPerMinute: 30,
    requestsPerDay: 1000,
    burstCapacity: 10,
  },
  pro: {
    name: "Professional",
    requestsPerMinute: 300,
    requestsPerDay: 50000,
    burstCapacity: 50,
  },
  enterprise: {
    name: "Enterprise",
    requestsPerMinute: 3000,
    requestsPerDay: 500000,
    burstCapacity: 200,
  },
};
 
async function getTierForApiKey(apiKey: string): Promise<RateLimitTier> {
  const cached = await redis.get(`tier:${apiKey}`);
  if (cached) return JSON.parse(cached);
 
  const tier = await db.query(
    "SELECT tier FROM api_keys WHERE key_hash = $1",
    [hashApiKey(apiKey)]
  );
 
  const result = tiers[tier?.tier || "free"];
  await redis.set(`tier:${apiKey}`, JSON.stringify(result), "EX", 300);
  return result;
}

Key Takeaways

Rate limiting protects your API from abuse and ensures fair access across all consumers. Choose the token bucket for APIs that benefit from burst allowances and the sliding window counter for smoother, more predictable limiting. Use Redis for distributed rate limiting so all application instances share the same counters.

Always return proper HTTP headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) so clients can self-regulate. Implement tiered rate limits that align with your pricing model—free users get lower limits, paying customers get higher limits. Separate rate limits for sensitive endpoints like authentication, which need much stricter controls than general API endpoints.

The best rate limiter is one your API consumers never notice because the limits are generous enough for normal usage while strict enough to protect against abuse.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX