An API without rate limiting is an open door. Any client can make thousands of requests per second, crash the server, or consume resources other users need. But excessive rate limiting blocks legitimate users using the API normally.

Rate limiting algorithms

Three approaches dominate practice:

Fixed Window

Counts requests within a fixed window (e.g., 100 requests per minute). Simple, but has the "edge burst" problem: 100 requests at second 59 of the window + 100 at second 0 of the next = 200 requests in 2 seconds.

Sliding Window Log

Keeps the timestamp of each request in the window. More accurate, but consumes memory proportional to the number of requests.

Token Bucket

The most used in production. Tokens are added at regular intervals. Each request consumes a token. When tokens run out, the request is rejected. Allows controlled bursts.

// Token bucket with Redis
async function isRateLimited(
  key: string,
  maxTokens: number,
  refillRate: number
): Promise<boolean> {
  const now = Date.now()
  const bucket = await redis.hgetall(`ratelimit:${key}`)

  if (!bucket.tokens) {
    // First request: create bucket
    await redis.hset(`ratelimit:${key}`, {
      tokens: maxTokens - 1,
      lastRefill: now
    })
    await redis.expire(`ratelimit:${key}`, 60)
    return false
  }

  // Refill tokens based on elapsed time
  const elapsed = now - Number(bucket.lastRefill)
  const refill = Math.floor(elapsed / 1000 * refillRate)
  const tokens = Math.min(maxTokens, Number(bucket.tokens) + refill)

  if (tokens <= 0) return true  // rate limited

  await redis.hset(`ratelimit:${key}`, {
    tokens: tokens - 1,
    lastRefill: now
  })

  return false
}

By key: IP, user, or API key

The rate limit key defines who is being limited:

  • By IP: protects against bots and anonymous abuse. Basic, but doesn't differentiate legitimate users from shared IPs (CGNAT, VPNs).
  • By authenticated user: fairer. Users pay by plan, and rate limit follows the plan.
  • By API key: for public APIs. Each developer gets their bucket, and abuse doesn't affect others.
  • Compound: global limit by IP + individual limit by user. Protects against DDoS and abuse simultaneously.

HTTP headers that communicate state

Clients need to know when they're approaching the limit. These headers are market standard:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 23
X-RateLimit-Reset: 1711234567
Retry-After: 30  // when returning 429

Retry-After is mandatory in 429 responses. Without it, the client doesn't know when to try again and may enter a retry loop.

Implementation with express-rate-limit

For Node.js, the standard implementation:

import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'

const limiter = rateLimit({
  windowMs: 60 * 1000,  // 1 minute
  max: 100,             // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),
  keyGenerator: (req) => req.user?.id || req.ip,
  handler: (req, res) => {
    res.status(429).json({
      error: {
        code: 'RATE_LIMITED',
        message: 'Too many requests. Try again later.',
        retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
      }
    })
  }
})

app.use('/api/', limiter)

Route-specific rate limiting

Not every route has the same cost. Login is expensive (bcrypt). Listings are cheap. Apply different limits:

// Login: 5 attempts per minute (protects against brute force)
app.use('/api/auth/login', rateLimit({ windowMs: 60000, max: 5 }))

// Listings: 100 per minute
app.use('/api/products', rateLimit({ windowMs: 60000, max: 100 }))

// Upload: 10 per hour
app.use('/api/upload', rateLimit({ windowMs: 3600000, max: 10 }))

What to do when rate limited

Beyond 429 with Retry-After, implement graceful degradation: cache the response longer, or return partial data when available. The user gets something instead of a pure error.