Skip to main content

API Rate Limiting Patterns

Rate limiting protects your API from abuse, prevents cascading failures, and enforces fair usage tiers. This guide covers all four algorithms, Redis implementations you can copy directly, Nginx configuration, and the response headers your clients need to back off gracefully.

✓ 4 algorithms compared ✓ Redis + Lua implementation ✓ Nginx config ✓ Response headers

Algorithm Comparison

Algorithm Allows burst Memory Accuracy Best for
Fixed windowYes (boundary burst)O(1)Low (boundary problem)Simple analytics limits
Sliding window logControlledO(requests)ExactLow-traffic, exact billing
Sliding window counterControlledO(1)~0.1% errorMost production APIs
Token bucketYes (up to capacity)O(1)HighAPIs with legitimate bursts
Leaky bucketNo (strict rate)O(queue)Exact output rateProtecting slow downstream

Redis: Fixed Window (INCR + EXPIRE)

// Node.js — fixed window rate limiter
import { createClient } from 'redis';

const redis = createClient();
await redis.connect();

async function fixedWindowRateLimit(
  userId: string,
  limitPerMinute: number
): Promise{{ allowed: boolean; remaining: number; resetIn: number }} {
  const windowSeconds = 60;
  const now = Math.floor(Date.now() / 1000);
  const windowStart = Math.floor(now / windowSeconds) * windowSeconds;
  const key = `rate:fixed:${userId}:${windowStart}`;

  const count = await redis.incr(key);

  if (count === 1) {
    // First request in this window — set expiry
    await redis.expire(key, windowSeconds);
  }

  const resetIn = windowStart + windowSeconds - now;

  return {
    allowed: count <= limitPerMinute,
    remaining: Math.max(0, limitPerMinute - count),
    resetIn,
  };
}

Redis: Sliding Window Counter (Lua — Atomic)

-- sliding_window.lua
-- KEYS[1]: current window key   e.g. rate:user:42:1704067200
-- KEYS[2]: previous window key  e.g. rate:user:42:1704067140
-- ARGV[1]: current timestamp (seconds)
-- ARGV[2]: window size in seconds
-- ARGV[3]: max requests per window
-- Returns: { allowed (0|1), current_count, remaining }

local currentKey  = KEYS[1]
local previousKey = KEYS[2]
local now         = tonumber(ARGV[1])
local windowSize  = tonumber(ARGV[2])
local limit       = tonumber(ARGV[3])

local currentCount  = tonumber(redis.call('GET', currentKey) or 0)
local previousCount = tonumber(redis.call('GET', previousKey) or 0)

-- Weight of previous window within the sliding window
local windowStart  = math.floor(now / windowSize) * windowSize
local elapsed      = now - windowStart
local prevWeight   = 1 - (elapsed / windowSize)

local estimatedCount = currentCount + math.floor(previousCount * prevWeight)

if estimatedCount >= limit then
  return { 0, estimatedCount, 0 }
end

-- Increment current window
local newCount = redis.call('INCR', currentKey)
if newCount == 1 then
  redis.call('EXPIRE', currentKey, windowSize * 2)
end

local remaining = limit - estimatedCount - 1
return { 1, estimatedCount + 1, remaining }
// Node.js — call the Lua script
const slidingWindowScript = fs.readFileSync('./sliding_window.lua', 'utf8');

async function slidingWindowRateLimit(userId: string, limit: number) {
  const windowSize = 60; // 1 minute
  const now = Math.floor(Date.now() / 1000);
  const currentWindow  = Math.floor(now / windowSize) * windowSize;
  const previousWindow = currentWindow - windowSize;

  const result = await redis.eval(slidingWindowScript, {
    keys: [
      `rate:sw:${userId}:${currentWindow}`,
      `rate:sw:${userId}:${previousWindow}`,
    ],
    arguments: [String(now), String(windowSize), String(limit)],
  }) as [number, number, number];

  return {
    allowed:   result[0] === 1,
    count:     result[1],
    remaining: result[2],
    resetAt:   currentWindow + windowSize,
  };
}

Token Bucket with Redis

-- token_bucket.lua
-- KEYS[1]: bucket key
-- ARGV[1]: current timestamp (ms float)
-- ARGV[2]: bucket capacity (max tokens)
-- ARGV[3]: refill rate (tokens per second)
-- ARGV[4]: tokens to consume (default 1)

local key      = KEYS[1]
local now      = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local rate     = tonumber(ARGV[3])
local consume  = tonumber(ARGV[4]) or 1

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

-- Refill tokens based on elapsed time
local elapsed  = math.max(0, now - lastRefill)
local newTokens = math.min(capacity, tokens + (elapsed / 1000) * rate)

if newTokens < consume then
  -- Not enough tokens — calculate wait time
  local waitMs = math.ceil((consume - newTokens) / rate * 1000)
  return { 0, math.floor(newTokens), waitMs }
end

-- Consume tokens
newTokens = newTokens - consume
redis.call('HMSET', key, 'tokens', newTokens, 'last_refill', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000))

return { 1, math.floor(newTokens), 0 }

Express Middleware with Proper Headers

// rate-limit.middleware.ts
import { Request, Response, NextFunction } from 'express';

export function createRateLimiter(limit: number) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const userId = req.user?.id ?? req.ip;
    const result = await slidingWindowRateLimit(userId, limit);

    // Set standard rate limit headers
    res.setHeader('X-RateLimit-Limit',     limit);
    res.setHeader('X-RateLimit-Remaining', result.remaining);
    res.setHeader('X-RateLimit-Reset',     result.resetAt);
    res.setHeader('RateLimit-Policy',      `${limit};w=60`);

    if (!result.allowed) {
      const retryAfter = result.resetAt - Math.floor(Date.now() / 1000);
      res.setHeader('Retry-After', retryAfter);

      return res.status(429).json({
        error: {
          code: 'RATE_LIMITED',
          message: 'Too many requests. Please slow down.',
          retry_after: retryAfter,
        },
      });
    }

    next();
  };
}

// Apply per-route
app.use('/api/v1/search',  createRateLimiter(10));   // 10/min — expensive
app.use('/api/v1/users',   createRateLimiter(100));  // 100/min — standard
app.use('/api/v1/webhooks',createRateLimiter(1000)); // 1000/min — high volume

Nginx Rate Limiting

# nginx.conf — http block
http {
  # Zone: 10MB stores ~160,000 IP addresses
  # Rate: 10 req/sec per IP (leaky bucket)
  limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

  # Per-user limiting using JWT sub claim (requires auth_request)
  limit_req_zone $http_x_user_id zone=user_limit:10m rate=100r/m;

  # Add headers to downstream
  limit_req_status 429;
  limit_conn_status 429;

  server {
    location /api/ {
      # Allow burst of 20, serve immediately (nodelay = token bucket behavior)
      limit_req zone=api_limit burst=20 nodelay;

      # Return proper JSON error
      error_page 429 @rate_limited;
      proxy_pass http://backend;
    }

    location @rate_limited {
      add_header Content-Type application/json;
      add_header Retry-After 1;
      return 429 '{"error":{"code":"RATE_LIMITED","message":"Too many requests"}}';
    }
  }
}

Frequently Asked Questions

What is the difference between token bucket and leaky bucket?▼

Token bucket allows bursting: tokens accumulate up to a maximum capacity and each request consumes one token. If tokens are available, the request proceeds immediately — so a burst of traffic up to the bucket capacity is allowed. Leaky bucket enforces a strictly constant output rate regardless of input burst. Requests join a queue and are processed at a fixed rate; if the queue is full, new requests are dropped. Token bucket is better for most APIs because it allows short bursts (page loads, mobile app startup) while still enforcing average rate limits. Leaky bucket is better for downstream protection where you need to protect a slow resource from any burst.

How should I choose between fixed window and sliding window rate limiting?▼

Fixed window is simpler to implement and has constant memory usage (one counter per window per key), but has a boundary burst problem: a client can send max requests at the end of one window and max requests at the start of the next, effectively doubling the allowed rate at window boundaries. Sliding window log fixes this exactly but requires storing a timestamp for every request (memory scales with request count). Sliding window counter (the common Redis hybrid) approximates sliding behavior using two fixed-window counters with weighted interpolation, giving near-perfect accuracy at constant memory cost. Use sliding window counter for any production API.

What Redis data structures are used for rate limiting?▼

Three approaches: (1) INCR + EXPIRE on a key like rate:user:42:2024010115 — simple fixed-window counter, O(1) per request. (2) Sorted set with ZADD + ZREMRANGEBYSCORE + ZCARD for sliding window log — stores timestamps as scores, removes old entries, counts remaining. Accurate but O(log N) and memory grows with request count. (3) Two-key sliding window counter using INCR on current and previous window keys with weighted interpolation — best balance of accuracy and performance. Use Lua scripts to make multi-command operations atomic, or use Redis modules like redis-cell (TOKEN_BUCKET command) for built-in token bucket.

What response headers should a rate-limited API return?▼

Standard headers: X-RateLimit-Limit (total requests allowed in window), X-RateLimit-Remaining (requests left in current window), X-RateLimit-Reset (Unix timestamp when the window resets or tokens refill), Retry-After (seconds to wait before retrying, required on 429 responses). The RateLimit header family is being standardized in IETF draft draft-ietf-httpapi-ratelimit-headers: RateLimit-Policy and RateLimit. Always include Retry-After on 429 — without it, clients that aggressively retry will amplify load rather than backing off. For token bucket, RateLimit-Reset should be the time until the next token is available.

How do I rate limit in Nginx without Redis?▼

Nginx's ngx_http_limit_req_module implements leaky bucket natively. Define a zone with limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; in the http block, then apply limit_req zone=api burst=20 nodelay; in your location block. The burst parameter allows up to 20 queued requests — nodelay serves them immediately (token bucket-like) rather than spacing them out (pure leaky bucket). For per-user limiting use $http_authorization or $cookie_session as the key. Nginx's in-memory zones are fast but not shared across workers by default — use a Lua-based solution (OpenResty) for per-worker accuracy across cluster nodes.

How do I handle distributed rate limiting across multiple API servers?▼

Single-node counters fail in a multi-server deployment because each server only sees its own traffic. Solutions: (1) Redis centralized store — all servers increment the same key, highest accuracy, adds ~0.5–1ms latency per request. (2) Approximate local + sync — each server keeps a local counter and periodically syncs to Redis, reducing latency at the cost of slight over-allowance during sync intervals. (3) API Gateway rate limiting (Kong, AWS API Gateway, Nginx Plus) — handles limiting before traffic reaches your servers. (4) Token bucket with occasional replenishment — servers pull a batch of tokens from Redis and serve locally until exhausted. For most APIs, option 1 or 3 is the right choice.

Share this tool: X / Twitter LinkedIn
✓ 60,000+ developers use DevPlaybook ★★★★★ 4.8 / 5 on Gumroad ✓ MIT licensed · Instant download ✓ No login required

Related Products

on Gumroad

You Might Also Need

More Free Tools

Redis Command Tester Redis Pipeline Builder OpenAPI Validator Redis Connection String Builder Redis Command Cheatsheet API Response Mock Generator API Response Time Tester OpenAPI Schema Validator OpenAPI Mock Generator API Test Case Generator Web Performance Checklist Redis TTL Calculator API Request Builder HTTP Security Headers Generator API Mock Server Config Generator HTTP Security Headers Checker API Rate Limit Calculator API Response Formatter & Analyzer HTTP API Tester LLM Cost Calculator AI Prompt Cost Calculator REST API Request Builder Lighthouse Score Simulator Content Security Policy Builder LLM Prompt Cost Calculator API Response Mocker HTTP Header Inspector AI Rate Limit Calculator Web Vitals Budget Planner Prompt Injection Tester GCP Service Account Key Auditor Core Web Vitals Guide HTTP Headers Analyzer API Key Generator AI Doc Generator — Pro Tool AI System Guardian .htaccess Generator HTTP Methods Reference HTTP Code Snippet Generator HTTP Status Code Reference HTTP Status Code Reference Interview Question Generator JSON Diff Viewer JSON Diff nginx Config Generator Password Strength Checker Rate Limiter Tester API Changelog Generator HTTP Mock Response Builder CSP Header Builder Web Vitals Scorer Rate Limit Calculator CORS Policy Builder SQL Index Advisor AWS IAM Policy Validator Haproxy Config Validator HTTP Cookie Parser HAR Diff Analyzer SBOM Generator HTTP Status Code Lookup MySQL Query Explainer PostgreSQL EXPLAIN Analyzer HTTP Status Code Lookup Vue Component Generator HTTP Status Code Reference LLM Context Window Calculator HTTP Header Analyzer LLM Token Counter Dockerfile Linter k6 Load Test Script Generator HTTP/2 Server Push Headers Builder Web Vitals Analyzer Prompt Token Estimator Database Index Analyzer Mock Service Worker (MSW) API Mocking Guide Pulumi Modern IaC Guide Core Web Vitals Calculator Database Index Advisor Docker Layer Optimizer HTML5 Canvas API Playground LLM Benchmark Calculator Token Context Calculator k6 Script Generator OpenAI Function Calling Generator Core Web Vitals Budget Calculator Bundle Size Budget Calculator Image Optimization Calculator TTFB Analyzer Token Counter & Cost Estimator AI API Rate Limit Calculator Password Strength Analyzer Database Query Cost Analyzer

Enjoying this tool? Get more like it.

Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.

DevPlaybook Pro 7 days free

Unlock AI-Powered Dev Tools

  • ⚡ AI Code Review, Doc Generator & SQL Builder
  • ⚡ All premium templates & early access
  • ⚡ Member discounts on Gumroad products
Start Free Trial →

$9/mo after trial · Cancel anytime

Want the full toolkit?

Get DevPlaybook Pro

Every template, guide, boilerplate, and automation script in one bundle. 13 premium products — grab them all at once and save big.

$79
$241 separately Save $162 (67% off)
Get DevPlaybook Pro — $79 →

MIT licensed · Instant download · No subscription

See what's included → Browse all deals →