Rate Limiting Strategies for Public APIs: Complete Implementation Guide

Rate limiting is one of the most critical defenses for public APIs. Without proper rate limiting, your API is vulnerable to abuse, denial-of-service attacks, resource exhaustion, and excessive costs.

According to Cloudflare’s 2025 API Security Report, over 30% of API traffic is malicious or abusive. Rate limiting is your first line of defense against this threat landscape.

In this comprehensive guide, we’ll explore production-ready rate limiting strategies, from basic implementations to distributed systems, with real-world code examples in Node.js, Redis, and Nginx.

Why Rate Limiting Matters

Rate limiting protects your API from:

1. Denial of Service (DoS) Attacks

Malicious actors flooding your API with requests to overwhelm your infrastructure.

2. Brute Force Attacks

Automated password guessing, credential stuffing, or API key enumeration attempts.

3. Resource Exhaustion

Unintentional abuse from poorly implemented clients or runaway scripts.

4. Cost Control

Preventing unexpected cloud bills from excessive API usage, especially with third-party service dependencies.

5. Fair Usage

Ensuring all users get reasonable access to shared resources.

6. Data Scraping

Preventing competitors or malicious actors from harvesting your data at scale.

Rate Limiting Algorithms

Different use cases require different algorithms. Let’s explore the most common strategies.

1. Fixed Window Counter

The simplest approach: count requests in fixed time windows.

How it works:

  • Divide time into fixed windows (e.g., 1-minute intervals)
  • Count requests within each window
  • Reset counter at window boundaries

Pros: Simple to implement, low memory usage

Cons: Burst traffic at window boundaries (the “thundering herd” problem)

// Basic fixed window implementation
class FixedWindowRateLimiter {
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.windows = new Map(); // key -> { count, resetAt }
  }
  
  isAllowed(key) {
    const now = Date.now();
    const window = this.windows.get(key);
    
    // No window exists or window expired
    if (!window || now >= window.resetAt) {
      this.windows.set(key, {
        count: 1,
        resetAt: now + this.windowMs
      });
      return { allowed: true, remaining: this.maxRequests - 1 };
    }
    
    // Within current window
    if (window.count < this.maxRequests) {
      window.count++;
      return {
        allowed: true,
        remaining: this.maxRequests - window.count
      };
    }
    
    // Rate limit exceeded
    return {
      allowed: false,
      remaining: 0,
      resetAt: window.resetAt
    };
  }
}

// Usage
const limiter = new FixedWindowRateLimiter(100, 60000); // 100 requests per minute

const result = limiter.isAllowed('user:123');
if (!result.allowed) {
  console.log(`Rate limit exceeded. Try again at ${new Date(result.resetAt)}`);
}

Problem: At 00:59, a user makes 100 requests. At 01:01, they make another 100 requests. That’s 200 requests in 2 seconds, bypassing the intended limit.

2. Sliding Window Log

Track exact timestamps of each request.

How it works:

  • Store timestamps of all requests
  • Remove timestamps older than the window
  • Count remaining timestamps

Pros: Prevents burst traffic at boundaries, precise

Cons: Higher memory usage (stores all timestamps)

class SlidingWindowLogRateLimiter {
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.logs = new Map(); // key -> [timestamps]
  }
  
  isAllowed(key) {
    const now = Date.now();
    const timestamps = this.logs.get(key) || [];
    
    // Remove timestamps outside the current window
    const validTimestamps = timestamps.filter(
      timestamp => now - timestamp < this.windowMs
    );
    
    if (validTimestamps.length < this.maxRequests) {
      validTimestamps.push(now);
      this.logs.set(key, validTimestamps);
      return {
        allowed: true,
        remaining: this.maxRequests - validTimestamps.length
      };
    }
    
    // Rate limit exceeded
    const oldestTimestamp = validTimestamps[0];
    return {
      allowed: false,
      remaining: 0,
      resetAt: oldestTimestamp + this.windowMs
    };
  }
}

// Usage
const limiter = new SlidingWindowLogRateLimiter(100, 60000);

3. Sliding Window Counter (Hybrid)

Combines fixed window efficiency with sliding window accuracy.

How it works:

  • Maintain counts for current and previous windows
  • Calculate weighted average based on time position in current window

Pros: Good balance of accuracy and efficiency

Cons: Approximation, not exact

class SlidingWindowCounterRateLimiter {
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.counters = new Map(); // key -> { current, previous, resetAt }
  }
  
  isAllowed(key) {
    const now = Date.now();
    let counter = this.counters.get(key);
    
    if (!counter || now >= counter.resetAt) {
      // Start new window
      this.counters.set(key, {
        current: 1,
        previous: counter?.current || 0,
        resetAt: now + this.windowMs
      });
      return { allowed: true, remaining: this.maxRequests - 1 };
    }
    
    // Calculate weighted count from current and previous windows
    const timeIntoWindow = now - (counter.resetAt - this.windowMs);
    const percentageIntoWindow = timeIntoWindow / this.windowMs;
    
    const weightedCount = 
      counter.current + 
      counter.previous * (1 - percentageIntoWindow);
    
    if (weightedCount < this.maxRequests) {
      counter.current++;
      return {
        allowed: true,
        remaining: Math.floor(this.maxRequests - weightedCount - 1)
      };
    }
    
    return {
      allowed: false,
      remaining: 0,
      resetAt: counter.resetAt
    };
  }
}

4. Token Bucket

The most flexible and widely used algorithm.

How it works:

  • Bucket holds tokens (up to a maximum capacity)
  • Tokens are added at a fixed rate
  • Each request consumes a token
  • Request denied if bucket is empty

Pros: Handles bursts gracefully, highly configurable

Cons: More complex implementation

class TokenBucketRateLimiter {
  constructor(capacity, refillRate, refillIntervalMs) {
    this.capacity = capacity; // Maximum tokens
    this.refillRate = refillRate; // Tokens added per interval
    this.refillIntervalMs = refillIntervalMs;
    this.buckets = new Map(); // key -> { tokens, lastRefill }
  }
  
  isAllowed(key, cost = 1) {
    const now = Date.now();
    let bucket = this.buckets.get(key);
    
    if (!bucket) {
      bucket = {
        tokens: this.capacity - cost,
        lastRefill: now
      };
      this.buckets.set(key, bucket);
      return { allowed: true, remaining: bucket.tokens };
    }
    
    // Refill tokens based on elapsed time
    const elapsedMs = now - bucket.lastRefill;
    const intervalsElapsed = Math.floor(elapsedMs / this.refillIntervalMs);
    
    if (intervalsElapsed > 0) {
      bucket.tokens = Math.min(
        this.capacity,
        bucket.tokens + (intervalsElapsed * this.refillRate)
      );
      bucket.lastRefill = now;
    }
    
    // Check if enough tokens available
    if (bucket.tokens >= cost) {
      bucket.tokens -= cost;
      return {
        allowed: true,
        remaining: bucket.tokens
      };
    }
    
    return {
      allowed: false,
      remaining: bucket.tokens
    };
  }
}

// Usage: 100 tokens capacity, refill 10 tokens every second
const limiter = new TokenBucketRateLimiter(100, 10, 1000);

// Expensive operation costs more tokens
const result = limiter.isAllowed('user:123', 5);

5. Leaky Bucket

Process requests at a constant rate, queueing excess.

How it works:

  • Requests enter a queue (bucket)
  • Requests are processed at a fixed rate
  • Queue has maximum capacity

Pros: Smooth traffic, predictable processing rate

Cons: Can introduce latency

class LeakyBucketRateLimiter {
  constructor(capacity, leakRate) {
    this.capacity = capacity; // Maximum queue size
    this.leakRate = leakRate; // Requests per second
    this.queues = new Map(); // key -> { queue, lastLeak }
  }
  
  async process(key, requestHandler) {
    const now = Date.now();
    let bucket = this.queues.get(key);
    
    if (!bucket) {
      bucket = { queue: [], lastLeak: now };
      this.queues.set(key, bucket);
    }
    
    // Leak (process) requests at fixed rate
    const elapsedMs = now - bucket.lastLeak;
    const requestsToLeak = Math.floor((elapsedMs / 1000) * this.leakRate);
    
    if (requestsToLeak > 0) {
      const leaked = bucket.queue.splice(0, requestsToLeak);
      bucket.lastLeak = now;
      
      // Process leaked requests
      for (const handler of leaked) {
        await handler();
      }
    }
    
    // Add new request to queue
    if (bucket.queue.length < this.capacity) {
      return new Promise((resolve, reject) => {
        bucket.queue.push(async () => {
          try {
            const result = await requestHandler();
            resolve(result);
          } catch (error) {
            reject(error);
          }
        });
      });
    }
    
    throw new Error('Rate limit exceeded: queue full');
  }
}

Production Implementation with Express and Redis

For production APIs, use Redis for distributed rate limiting across multiple server instances.

Express Middleware with Redis

// rate-limiter.js
const Redis = require('ioredis');
const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  password: process.env.REDIS_PASSWORD
});

class RedisRateLimiter {
  constructor(options = {}) {
    this.maxRequests = options.maxRequests || 100;
    this.windowMs = options.windowMs || 60000;
    this.keyPrefix = options.keyPrefix || 'rl:';
  }
  
  async slidingWindowCounter(key) {
    const now = Date.now();
    const windowStart = now - this.windowMs;
    const redisKey = `${this.keyPrefix}${key}`;
    
    const pipeline = redis.pipeline();
    
    // Remove old entries
    pipeline.zremrangebyscore(redisKey, 0, windowStart);
    
    // Count requests in current window
    pipeline.zcard(redisKey);
    
    // Add current request
    pipeline.zadd(redisKey, now, `${now}-${Math.random()}`);
    
    // Set expiration
    pipeline.expire(redisKey, Math.ceil(this.windowMs / 1000));
    
    const results = await pipeline.exec();
    const count = results[1][1]; // Get count from zcard
    
    const allowed = count < this.maxRequests;
    const remaining = Math.max(0, this.maxRequests - count - 1);
    
    return {
      allowed,
      remaining,
      limit: this.maxRequests,
      resetAt: now + this.windowMs
    };
  }
  
  async tokenBucket(key, cost = 1) {
    const now = Date.now();
    const redisKey = `${this.keyPrefix}tb:${key}`;
    
    // Lua script for atomic token bucket operation
    const script = `
      local key = KEYS[1]
      local capacity = tonumber(ARGV[1])
      local refillRate = tonumber(ARGV[2])
      local refillInterval = tonumber(ARGV[3])
      local cost = tonumber(ARGV[4])
      local now = tonumber(ARGV[5])
      
      local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
      local tokens = tonumber(bucket[1]) or capacity
      local lastRefill = tonumber(bucket[2]) or now
      
      -- Refill tokens
      local elapsed = now - lastRefill
      local intervals = math.floor(elapsed / refillInterval)
      
      if intervals > 0 then
        tokens = math.min(capacity, tokens + (intervals * refillRate))
        lastRefill = now
      end
      
      -- Check if request allowed
      if tokens >= cost then
        tokens = tokens - cost
        redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill)
        redis.call('EXPIRE', key, 3600)
        return {1, tokens}
      else
        return {0, tokens}
      end
    `;
    
    const capacity = this.maxRequests;
    const refillRate = Math.ceil(this.maxRequests / (this.windowMs / 1000));
    const refillInterval = 1000; // 1 second
    
    const result = await redis.eval(
      script,
      1,
      redisKey,
      capacity,
      refillRate,
      refillInterval,
      cost,
      now
    );
    
    const allowed = result[0] === 1;
    const remaining = result[1];
    
    return {
      allowed,
      remaining,
      limit: capacity
    };
  }
  
  middleware(options = {}) {
    const algorithm = options.algorithm || 'sliding-window';
    const keyGenerator = options.keyGenerator || ((req) => req.ip);
    const handler = options.handler || this.defaultHandler;
    
    return async (req, res, next) => {
      try {
        const key = keyGenerator(req);
        
        let result;
        if (algorithm === 'token-bucket') {
          result = await this.tokenBucket(key, options.cost || 1);
        } else {
          result = await this.slidingWindowCounter(key);
        }
        
        // Set standard rate limit headers
        res.setHeader('X-RateLimit-Limit', result.limit);
        res.setHeader('X-RateLimit-Remaining', result.remaining);
        
        if (result.resetAt) {
          res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetAt / 1000));
        }
        
        if (!result.allowed) {
          return handler(req, res);
        }
        
        next();
      } catch (error) {
        console.error('Rate limiting error:', error);
        // Fail open - allow request if rate limiter fails
        next();
      }
    };
  }
  
  defaultHandler(req, res) {
    res.status(429).json({
      error: 'Too Many Requests',
      message: 'Rate limit exceeded. Please try again later.',
      retryAfter: res.getHeader('X-RateLimit-Reset')
    });
  }
}

module.exports = RedisRateLimiter;

Using the Middleware

// server.js
const express = require('express');
const RedisRateLimiter = require('./rate-limiter');

const app = express();

// Global rate limiter: 100 requests per minute
const globalLimiter = new RedisRateLimiter({
  maxRequests: 100,
  windowMs: 60000,
  keyPrefix: 'global:'
});

app.use(globalLimiter.middleware({
  keyGenerator: (req) => req.ip
}));

// API key-based rate limiter: 1000 requests per hour
const apiKeyLimiter = new RedisRateLimiter({
  maxRequests: 1000,
  windowMs: 3600000,
  keyPrefix: 'api:'
});

app.use('/api', apiKeyLimiter.middleware({
  keyGenerator: (req) => req.headers['x-api-key'] || req.ip
}));

// Expensive endpoint with token bucket
const expensiveLimiter = new RedisRateLimiter({
  maxRequests: 10,
  windowMs: 60000,
  keyPrefix: 'expensive:'
});

app.post('/api/expensive-operation', expensiveLimiter.middleware({
  algorithm: 'token-bucket',
  cost: 5, // Costs 5 tokens
  keyGenerator: (req) => req.user?.id || req.ip
}), (req, res) => {
  // Expensive operation
  res.json({ success: true });
});

// Different limits for authenticated vs anonymous users
app.get('/api/data', (req, res, next) => {
  const limiter = req.user 
    ? new RedisRateLimiter({ maxRequests: 1000, windowMs: 60000 })
    : new RedisRateLimiter({ maxRequests: 100, windowMs: 60000 });
  
  return limiter.middleware({
    keyGenerator: (req) => req.user?.id || req.ip
  })(req, res, next);
}, (req, res) => {
  res.json({ data: [...] });
});

app.listen(3000, () => {
  console.log('API server running on port 3000');
});

Advanced Strategies

1. Tiered Rate Limiting

Different limits based on user tier:

const RATE_LIMIT_TIERS = {
  free: { maxRequests: 100, windowMs: 3600000 }, // 100/hour
  basic: { maxRequests: 1000, windowMs: 3600000 }, // 1000/hour
  premium: { maxRequests: 10000, windowMs: 3600000 }, // 10000/hour
  enterprise: { maxRequests: 100000, windowMs: 3600000 } // 100000/hour
};

function getTierLimiter(userTier) {
  const config = RATE_LIMIT_TIERS[userTier] || RATE_LIMIT_TIERS.free;
  return new RedisRateLimiter(config);
}

app.use('/api', async (req, res, next) => {
  const tier = req.user?.tier || 'free';
  const limiter = getTierLimiter(tier);
  
  return limiter.middleware({
    keyGenerator: (req) => `${tier}:${req.user?.id || req.ip}`
  })(req, res, next);
});

2. Dynamic Rate Limiting

Adjust limits based on server load:

const os = require('os');

function getDynamicLimit() {
  const cpuUsage = os.loadavg()[0] / os.cpus().length;
  
  if (cpuUsage > 0.8) {
    return { maxRequests: 50, windowMs: 60000 }; // Reduce under load
  } else if (cpuUsage > 0.5) {
    return { maxRequests: 100, windowMs: 60000 };
  } else {
    return { maxRequests: 200, windowMs: 60000 }; // Normal load
  }
}

app.use((req, res, next) => {
  const config = getDynamicLimit();
  const limiter = new RedisRateLimiter(config);
  return limiter.middleware()(req, res, next);
});

3. Geographic Rate Limiting

Different limits based on geographic location:

const geoip = require('geoip-lite');

const GEO_RATE_LIMITS = {
  US: { maxRequests: 200, windowMs: 60000 },
  EU: { maxRequests: 200, windowMs: 60000 },
  default: { maxRequests: 100, windowMs: 60000 }
};

app.use((req, res, next) => {
  const geo = geoip.lookup(req.ip);
  const region = geo?.country || 'default';
  
  const config = GEO_RATE_LIMITS[region] || GEO_RATE_LIMITS.default;
  const limiter = new RedisRateLimiter(config);
  
  return limiter.middleware({
    keyGenerator: (req) => `${region}:${req.ip}`
  })(req, res, next);
});

4. Endpoint-Specific Limits

const ENDPOINT_LIMITS = {
  'GET:/api/users': { maxRequests: 1000, windowMs: 60000 },
  'POST:/api/users': { maxRequests: 10, windowMs: 60000 },
  'GET:/api/search': { maxRequests: 100, windowMs: 60000 },
  'POST:/api/auth/login': { maxRequests: 5, windowMs: 300000 }, // 5 per 5 min
};

app.use((req, res, next) => {
  const endpoint = `${req.method}:${req.path}`;
  const config = ENDPOINT_LIMITS[endpoint] || { maxRequests: 100, windowMs: 60000 };
  
  const limiter = new RedisRateLimiter(config);
  return limiter.middleware({
    keyGenerator: (req) => `${endpoint}:${req.user?.id || req.ip}`
  })(req, res, next);
});

Rate Limiting with Nginx

For high-performance rate limiting at the edge, use Nginx:

# nginx.conf

http {
  # Define rate limit zones
  
  # Global IP-based limit: 100 requests per minute
  limit_req_zone $binary_remote_addr zone=global:10m rate=100r/m;
  
  # API key-based limit: 1000 requests per minute
  limit_req_zone $http_x_api_key zone=api_key:10m rate=1000r/m;
  
  # Login endpoint: 5 requests per minute
  limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
  
  # Expensive operations: 10 requests per minute
  limit_req_zone $binary_remote_addr zone=expensive:10m rate=10r/m;
  
  server {
    listen 80;
    server_name api.example.com;
    
    # Global rate limit
    location /api {
      limit_req zone=global burst=20 nodelay;
      limit_req_status 429;
      
      proxy_pass http://backend;
    }
    
    # Strict rate limit for login
    location /api/auth/login {
      limit_req zone=login burst=2;
      limit_req_status 429;
      
      proxy_pass http://backend;
    }
    
    # Expensive operations
    location /api/reports {
      limit_req zone=expensive burst=5;
      limit_req_status 429;
      
      proxy_pass http://backend;
    }
    
    # API key-based rate limiting
    location /api/premium {
      # Use API key if present, otherwise IP
      set $limit_key $http_x_api_key;
      if ($limit_key = "") {
        set $limit_key $binary_remote_addr;
      }
      
      limit_req zone=api_key burst=50 nodelay;
      limit_req_status 429;
      
      proxy_pass http://backend;
    }
    
    # Custom error page for rate limits
    error_page 429 /rate_limit_error.json;
    location = /rate_limit_error.json {
      internal;
      default_type application/json;
      return 429 '{"error":"Too Many Requests","message":"Rate limit exceeded"}';
    }
  }
}

Cloudflare Rate Limiting

For global CDN-level rate limiting:

// Cloudflare Worker for advanced rate limiting

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  const url = new URL(request.url);
  const clientIP = request.headers.get('CF-Connecting-IP');
  const apiKey = request.headers.get('X-API-Key');
  
  // Use API key if present, otherwise IP
  const identifier = apiKey || clientIP;
  
  // Define rate limit
  const rateLimit = {
    limit: 100,
    period: 60 // seconds
  };
  
  // Rate limit key
  const key = `rate_limit:${identifier}:${url.pathname}`;
  
  // Check rate limit using Cloudflare KV
  const current = await RATE_LIMIT_KV.get(key);
  const count = current ? parseInt(current) : 0;
  
  if (count >= rateLimit.limit) {
    return new Response(JSON.stringify({
      error: 'Rate limit exceeded',
      limit: rateLimit.limit,
      period: rateLimit.period
    }), {
      status: 429,
      headers: {
        'Content-Type': 'application/json',
        'X-RateLimit-Limit': rateLimit.limit,
        'X-RateLimit-Remaining': 0
      }
    });
  }
  
  // Increment counter
  await RATE_LIMIT_KV.put(key, (count + 1).toString(), {
    expirationTtl: rateLimit.period
  });
  
  // Forward request
  const response = await fetch(request);
  
  // Add rate limit headers
  const newHeaders = new Headers(response.headers);
  newHeaders.set('X-RateLimit-Limit', rateLimit.limit);
  newHeaders.set('X-RateLimit-Remaining', rateLimit.limit - count - 1);
  
  return new Response(response.body, {
    status: response.status,
    headers: newHeaders
  });
}

Best Practices

✅ Return Standard Headers

Follow RFC 6585 and provide clear rate limit information:

res.setHeader('X-RateLimit-Limit', 100);
res.setHeader('X-RateLimit-Remaining', 95);
res.setHeader('X-RateLimit-Reset', 1659312000);
res.setHeader('Retry-After', 60);

✅ Use Multiple Layers

Combine different rate limiting strategies:

// Layer 1: Nginx (edge, fast)
// Layer 2: Application middleware (business logic)
// Layer 3: Per-endpoint limits
// Layer 4: Per-user tier limits

✅ Fail Open on Errors

If your rate limiter fails, allow the request through:

try {
  const result = await rateLimiter.check(key);
  if (!result.allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded' });
  }
} catch (error) {
  console.error('Rate limiter error:', error);
  // Fail open - allow request
}

✅ Monitor and Alert

Track rate limit metrics:

const { Counter, Histogram } = require('prom-client');

const rateLimitCounter = new Counter({
  name: 'rate_limit_total',
  help: 'Total rate limit checks',
  labelNames: ['result', 'endpoint']
});

const rateLimitExceeded = new Counter({
  name: 'rate_limit_exceeded_total',
  help: 'Total rate limit violations',
  labelNames: ['identifier', 'endpoint']
});

// In middleware
if (result.allowed) {
  rateLimitCounter.inc({ result: 'allowed', endpoint: req.path });
} else {
  rateLimitCounter.inc({ result: 'blocked', endpoint: req.path });
  rateLimitExceeded.inc({ identifier: key, endpoint: req.path });
}

✅ Provide Clear Error Messages

res.status(429).json({
  error: 'Rate Limit Exceeded',
  message: 'You have exceeded the rate limit for this endpoint',
  limit: 100,
  remaining: 0,
  resetAt: new Date(resetTimestamp).toISOString(),
  retryAfter: 60,
  documentation: 'https://api.example.com/docs/rate-limits'
});

✅ Consider Cost-Based Limiting

Different operations consume different resources:

const OPERATION_COSTS = {
  'GET:/api/users/:id': 1,
  'GET:/api/search': 5,
  'POST:/api/reports': 10,
  'POST:/api/bulk-import': 50
};

const cost = OPERATION_COSTS[endpoint] || 1;
const result = await tokenBucketLimiter.check(key, cost);

Testing Rate Limiters

// rate-limiter.test.js
const { expect } = require('chai');
const RedisRateLimiter = require('./rate-limiter');

describe('Rate Limiter', () => {
  let limiter;
  
  beforeEach(() => {
    limiter = new RedisRateLimiter({
      maxRequests: 5,
      windowMs: 1000
    });
  });
  
  it('should allow requests under limit', async () => {
    for (let i = 0; i < 5; i++) {
      const result = await limiter.slidingWindowCounter('test-key');
      expect(result.allowed).to.be.true;
    }
  });
  
  it('should block requests over limit', async () => {
    // Exhaust limit
    for (let i = 0; i < 5; i++) {
      await limiter.slidingWindowCounter('test-key');
    }
    
    // Next request should be blocked
    const result = await limiter.slidingWindowCounter('test-key');
    expect(result.allowed).to.be.false;
  });
  
  it('should reset after window expires', async () => {
    // Exhaust limit
    for (let i = 0; i < 5; i++) {
      await limiter.slidingWindowCounter('test-key');
    }
    
    // Wait for window to expire
    await new Promise(resolve => setTimeout(resolve, 1100));
    
    // Should allow requests again
    const result = await limiter.slidingWindowCounter('test-key');
    expect(result.allowed).to.be.true;
  });
  
  it('should track different keys independently', async () => {
    await limiter.slidingWindowCounter('key1');
    await limiter.slidingWindowCounter('key2');
    
    const result1 = await limiter.slidingWindowCounter('key1');
    const result2 = await limiter.slidingWindowCounter('key2');
    
    expect(result1.remaining).to.equal(3);
    expect(result2.remaining).to.equal(3);
  });
});

Conclusion

Rate limiting is essential for building robust, secure, and scalable APIs. By implementing the strategies outlined in this guide, you can:

  • Protect your API from abuse and DoS attacks
  • Ensure fair resource allocation across users
  • Control costs and prevent resource exhaustion
  • Provide a better experience for legitimate users
  • Meet SLA commitments and maintain service quality

Key Takeaways:

  • Choose the right algorithm for your use case (token bucket for flexibility, sliding window for accuracy)
  • Use Redis for distributed rate limiting across multiple servers
  • Implement multiple layers (edge, application, endpoint, user tier)
  • Return standard rate limit headers
  • Monitor violations and adjust limits based on real usage patterns
  • Fail open on errors to maintain availability

Start with simple IP-based rate limiting, then evolve to more sophisticated strategies as your API grows.

For more API security best practices, check out our guides on API Design Patterns and JWT Authentication.


Ready to implement rate limiting? Start with the Redis-based sliding window implementation for production-ready, distributed rate limiting that scales with your infrastructure.