Rate Limiting

Rate limiting is a fundamental traffic control technique that limits the number of requests a client can make to an API or web service within a specific time window, protecting infrastructure against overload, denial-of-service attacks (DoS/DDoS), automated abuse, malicious scraping and improper use of limited computational resources. In a scenario where modern APIs serve millions of requests per second coming from mobile applications, partner integrations, legitimate bots and potentially malicious attackers, the absence of adequate rate limiting can result in explosive operational costs, severe performance degradation for legitimate users, vulnerabilities to credential stuffing and brute force attacks, and even total service unavailability. Effective rate limiting implementation requires a deep understanding of different algorithms (token bucket, leaky bucket, fixed window, sliding window), considerations about distributed architecture where multiple servers need to share rate counters, client identification strategies (IP, API key, JWT, session), differentiated policies by user tier (free, premium, enterprise), and mechanisms for clear communication through standardized HTTP headers that inform clients about limits, current consumption and reset time. This article explores algorithms, implementation patterns, tools and best practices for building robust rate limiting systems.

Rate Limiting Algorithms

1. Token Bucket (Most Common)

      # Concept: Bucket with maximum token capacity
      # Tokens are added at a constant rate
      # Each request consumes 1 token
      # If bucket empty, request is rejected
      Capacity: 100 tokens
      Refill rate: 10 tokens/second
      Advantages:
      - Allows controlled bursts (full bucket)
      - Simple to implement
      - Flexible for different traffic patterns
      Disadvantages:
      - May allow bursts that overload the system
      - Requires tracking of last refill
      Implementation:
      class TokenBucket {
      constructor(capacity, refillRate) {
      this.capacity = capacity;
      this.tokens = capacity;
      this.refillRate = refillRate;
      this.lastRefill = Date.now();
      }
      consume(count = 1) {
      this.refill();
      if (this.tokens >= count) {
      this.tokens -= count;
      return true;
      }
      return false;
      }
      refill() {
      const now = Date.now();
      const elapsed = (now - this.lastRefill) / 1000;
      const tokensToAdd = elapsed * this.refillRate;
      this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
      this.lastRefill = now;
      }
      }
      

2. Leaky Bucket

      # Concept: Queue with constant leak
      # Requests enter the bucket (queue)
      # Processed at a constant rate (leak)
      # If queue full, requests are rejected
      Advantages:
      - Smooths out bursts (traffic shaping)
      - Constant and predictable output rate
      - Protects backend from spikes
      Disadvantages:
      - May add latency (queueing)
      - Implementation complexity
      Usage:
      - Traffic shaping
      - Network gateways
      - When constant output rate is critical
      

3. Fixed Window Counter

      # Concept: Counter per fixed time window
      # Example: 100 requests per minute
      # Reset at the start of each minute (XX:00, XX:01, XX:02...)
      Advantages:
      - Extremely simple
      - Memory efficient
      - Easy to understand
      Disadvantages:
      - Edge case: 200 requests in 1 second
      (100 at the end of minute 1, 100 at the start of minute 2)
      - Allows bursts at window boundaries
      Redis implementation:
      INCR user:123:2024-01-15:14:30
      EXPIRE user:123:2024-01-15:14:30 60
      GET user:123:2024-01-15:14:30  # If > 100, reject
      

4. Sliding Window Log

      # Concept: Log of request timestamps
      # Removes requests outside the window
      # Counts requests within the sliding window
      Advantages:
      - Perfect accuracy
      - No fixed window edge cases
      - Distributes rate uniformly
      Disadvantages:
      - Memory intensive (stores all timestamps)
      - Performance degrades with high traffic
      Redis implementation (Sorted Set):
      ZADD user:123 <timestamp> <request-id>
      ZREMRANGEBYSCORE user:123 0 <timestamp-60s>  # Remove old ones
      ZCARD user:123  # Count requests
      If > 100, reject
      

5. Sliding Window Counter (Hybrid)

      # Concept: Combines fixed window with sliding
      # Uses counters from previous windows with weight
      # Estimates rate in a sliding window
      Example: Limit 100/minute
      Current window (14:30): 70 requests
      Previous window (14:29): 90 requests
      Elapsed in current window: 40s (66.7%)
      Estimate: 90 * (1 - 0.667) + 70 = 30 + 70 = 100
      Advantages:
      - Accuracy close to sliding log
      - Memory efficient (only 2 counters)
      - Smooths out bursts
      Disadvantages:
      - Estimate (not exact)
      - More complex than fixed window
      

Distributed Rate Limiting

      # Problem: Multiple servers need to share state
      # Solutions:
      1. Centralized Redis (Most Common)
      const redis = require('redis');
      const client = redis.createClient();
      async function checkRateLimit(userId) {
      const key = \`rate:\$:\${getCurrentWindow()}\`;
      const count = await client.incr(key);
      if (count === 1) {
      await client.expire(key, 60); // 60 seconds
      }
      return count <= 100; // Limit: 100/min
      }
      2. Redis Lua Script (Atomic)
      const luaScript = \`
      local key = KEYS[1]
      local limit = tonumber(ARGV[1])
      local current = redis.call('incr', key)
      if current == 1 then
      redis.call('expire', key, ARGV[2])
      end
      if current > limit then
      return 0
      end
      return 1
      \`;
      3. Sticky Sessions + Local Counters
      - Always route user to the same server
      - Local counter on the server
      - Problem: Does not work well with auto-scaling
      4. Gossip Protocol
      - Servers share state via gossip
      - Eventual consistency
      - More complex, used at high scale
      

Standard HTTP Headers

      # Standards (RFCs)
      X-RateLimit-Limit: 100
      X-RateLimit-Remaining: 45
      X-RateLimit-Reset: 1640000000  # Unix timestamp
      # When limit exceeded
      HTTP/1.1 429 Too Many Requests
      Retry-After: 60  # Seconds until retry
      X-RateLimit-Limit: 100
      X-RateLimit-Remaining: 0
      X-RateLimit-Reset: 1640000060
      {
      "error": "Rate limit exceeded",
      "retryAfter": 60,
      "limit": 100
      }
      # GitHub style (more informative)
      X-RateLimit-Limit: 5000
      X-RateLimit-Remaining: 4999
      X-RateLimit-Reset: 1372700873
      X-RateLimit-Used: 1
      X-RateLimit-Resource: core
      

Implementation with Express.js

      const rateLimit = require('express-rate-limit');
      const RedisStore = require('rate-limit-redis');
      const redis = require('redis');
      const client = redis.createClient();
      // Basic rate limiter
      const limiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 100, // Limit each IP to 100 requests per windowMs
      standardHeaders: true, // Return rate limit info in headers
      legacyHeaders: false,
      message: 'Too many requests, please try again later.'
      });
      app.use('/api/', limiter);
      // Redis-based distributed rate limiting
      const distributedLimiter = rateLimit({
      store: new RedisStore({
      client: client,
      prefix: 'rate-limit:',
      }),
      windowMs: 60 * 1000,
      max: 10,
      standardHeaders: true,
      });
      // Different limits per route
      const authLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 5, // Stricter for auth endpoints
      skipSuccessfulRequests: true, // Don't count successful logins
      });
      app.post('/api/login', authLimiter, loginHandler);
      // Custom key function (rate limit by user ID instead of IP)
      const userLimiter = rateLimit({
      windowMs: 60 * 1000,
      max: 100,
      keyGenerator: (req) => req.user.id, // Requires auth middleware
      });
      

Tiered Rate Limiting

      // Different limits based on user tier
      function getRateLimit(user) {
      const tiers = {
      free: { windowMs: 3600000, max: 100 },      // 100/hour
      basic: { windowMs: 3600000, max: 1000 },    // 1000/hour
      premium: { windowMs: 3600000, max: 10000 }, // 10k/hour
      enterprise: { windowMs: 3600000, max: 100000 } // 100k/hour
      };
      return tiers[user.tier] || tiers.free;
      }
      app.use(async (req, res, next) => {
      const user = await getUserFromToken(req);
      const limits = getRateLimit(user);
      const limiter = rateLimit({
      ...limits,
      keyGenerator: () => user.id,
      });
      limiter(req, res, next);
      });
      

Tools and Services

  • Redis: Distributed counters, automatic expire
  • Kong: API Gateway with rate limiting plugin
  • Nginx rate limiting: limit_req_zone, limit_conn_zone
  • Cloudflare: CDN-level rate limiting
  • AWS API Gateway: Built-in throttling
  • express-rate-limit: Express middleware
  • Tyk: Open-source API gateway

Best Practices

  • Choose the right algorithm: Token bucket for general APIs, sliding window for accuracy
  • Differentiated limits: Auth endpoints more restrictive, read-only more permissive
  • Clear communication: Informative headers, useful error messages
  • Whitelist: IPs of trusted partners, health checks
  • Monitoring: Alert when users frequently hit limits
  • Graceful degradation: Return cached data if possible
  • Distributed state: Use Redis for multi-server deployments
  • Cost-based limiting: Expensive operations consume more tokens

Recommendations

For modern APIs, implement token bucket with Redis for distributed rate limiting. Use sliding window counter when you need accuracy without memory overhead. Configure differentiated limits: 5 req/min for login, 100 req/min for reads, 10 req/min for write operations. Always return informative headers and implement retry logic with exponential backoff on clients.