Skip to content
Codeloom
REST APIs

REST API Rate Limiting: Token Bucket, Sliding Window & Implementation

Protect your REST API from abuse with rate limiting. Learn token bucket, sliding window, and fixed window algorithms with real implementation examples and standard headers.

·8 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • Why rate limiting matters and what problems it solves
  • How token bucket, sliding window, and fixed window algorithms work
  • Standard rate limit headers (RateLimit draft RFC)
  • How to implement rate limiting in Node.js with Redis
  • Client-side strategies for handling 429 responses

Prerequisites

  • Basic understanding of REST APIs and HTTP
  • Familiarity with Node.js or similar backend language

Rate limiting controls how many requests a client can make to your API within a given time window. Without it, a single misbehaving client, a bot swarm, or even a bug in a mobile app can bring your entire service down.

This article covers the three most common algorithms, the headers your API should return, and a working implementation you can drop into a Node.js service.

Why rate limit?

Rate limiting solves several problems at once:

  • Availability — prevents a single client from monopolizing server resources
  • Fairness — ensures all clients get a reasonable share of capacity
  • Cost control — protects downstream services and databases from overload
  • Security — slows down brute-force attacks and credential stuffing
  • Compliance — many third-party APIs require you to enforce limits on your own consumers

The three main algorithms

1. Fixed window

The simplest approach. Divide time into fixed intervals (e.g., 1-minute windows) and count requests per client in each window.

Window: 14:00:00 – 14:00:59  →  limit: 100 requests
Window: 14:01:00 – 14:01:59  →  counter resets

Pros: Simple to implement, low memory usage.

Cons: Vulnerable to burst at window boundaries. A client can send 100 requests at 14:00:59 and another 100 at 14:01:00 — 200 requests in 2 seconds.

// Fixed window in-memory (illustration only)
const windows = new Map();

function fixedWindowCheck(clientId, limit, windowSizeMs) {
  const now = Date.now();
  const windowKey = Math.floor(now / windowSizeMs);
  const key = `${clientId}:${windowKey}`;

  const current = windows.get(key) || 0;
  if (current >= limit) {
    return { allowed: false, remaining: 0 };
  }

  windows.set(key, current + 1);
  return { allowed: true, remaining: limit - current - 1 };
}

2. Sliding window log

Track the timestamp of every request. When a new request arrives, count how many timestamps fall within the last N seconds.

const logs = new Map();

function slidingWindowLogCheck(clientId, limit, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;

  if (!logs.has(clientId)) logs.set(clientId, []);
  const timestamps = logs.get(clientId);

  // Remove expired entries
  while (timestamps.length > 0 && timestamps[0] <= windowStart) {
    timestamps.shift();
  }

  if (timestamps.length >= limit) {
    return { allowed: false, remaining: 0 };
  }

  timestamps.push(now);
  return { allowed: true, remaining: limit - timestamps.length };
}

Pros: No boundary burst problem — the window truly slides.

Cons: High memory usage. Storing every timestamp for every client gets expensive at scale.

3. Token bucket

The most popular algorithm in production. Each client has a “bucket” that holds tokens. Tokens are added at a fixed rate. Each request consumes one token. If the bucket is empty, the request is rejected.

class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;       // max tokens
    this.tokens = capacity;         // start full
    this.refillRate = refillRate;   // tokens per second
    this.lastRefill = Date.now();
  }

  tryConsume() {
    this.refill();
    if (this.tokens < 1) {
      return { allowed: false, remaining: 0 };
    }
    this.tokens -= 1;
    return { allowed: true, remaining: Math.floor(this.tokens) };
  }

  refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(
      this.capacity,
      this.tokens + elapsed * this.refillRate
    );
    this.lastRefill = now;
  }
}

Pros: Allows short bursts (up to bucket capacity) while enforcing a sustained rate. Simple math, low memory.

Cons: Slightly more complex than fixed window. Requires storing two values per client (tokens and last refill time).

Choosing an algorithm

FactorFixed WindowSliding WindowToken Bucket
Burst handlingPoorGoodGood (controlled)
Memory usageLowHighLow
ImplementationSimpleModerateModerate
FairnessModerateHighHigh
Production useSmall APIsAnalyticsMost APIs

Rule of thumb: Use token bucket for general-purpose rate limiting. Use sliding window when you need precise per-second accuracy (e.g., billing).

Standard rate limit headers

The IETF draft RFC (draft-ietf-httpapi-ratelimit-headers) defines three headers your API should return on every response:

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 58
HeaderMeaning
RateLimit-LimitMaximum requests allowed in the current window
RateLimit-RemainingHow many requests the client has left
RateLimit-ResetSeconds until the limit resets

When a client exceeds the limit, return 429 Too Many Requests with a Retry-After header:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30

{
  "type": "https://api.example.com/errors/rate-limited",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "You have exceeded 100 requests per minute. Try again in 30 seconds."
}

Production implementation with Redis

In-memory rate limiting breaks in multi-server deployments. Use Redis for shared state.

import express from 'express';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const app = express();

async function tokenBucketMiddleware(req, res, next) {
  const clientId = req.headers['x-api-key'] || req.ip;
  const key = `ratelimit:${clientId}`;
  const capacity = 100;
  const refillRate = 100 / 60; // 100 per minute

  // Lua script for atomic token bucket
  const luaScript = `
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])

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

    if tokens == nil then
      tokens = capacity
      last_refill = now
    end

    local elapsed = (now - last_refill) / 1000
    tokens = math.min(capacity, tokens + elapsed * refill_rate)

    if tokens < 1 then
      redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
      redis.call('EXPIRE', key, 120)
      return {0, math.ceil((1 - tokens) / refill_rate)}
    end

    tokens = tokens - 1
    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', key, 120)
    return {1, math.floor(tokens)}
  `;

  const [allowed, value] = await redis.eval(
    luaScript, 1, key, capacity, refillRate, Date.now()
  );

  res.set('RateLimit-Limit', String(capacity));

  if (!allowed) {
    const retryAfter = value;
    res.set('RateLimit-Remaining', '0');
    res.set('RateLimit-Reset', String(retryAfter));
    res.set('Retry-After', String(retryAfter));
    return res.status(429).json({
      type: 'https://api.example.com/errors/rate-limited',
      title: 'Rate limit exceeded',
      status: 429,
      detail: `Try again in ${retryAfter} seconds.`,
    });
  }

  res.set('RateLimit-Remaining', String(value));
  res.set('RateLimit-Reset', '60');
  next();
}

app.use(tokenBucketMiddleware);

app.get('/api/resources', (req, res) => {
  res.json({ data: ['item1', 'item2'] });
});

app.listen(3000);

The Lua script runs atomically inside Redis, so there are no race conditions between multiple app servers.

Tiered rate limits

Most production APIs apply different limits to different plans:

const RATE_LIMITS = {
  free:       { capacity: 60,    window: 60 },  // 60/min
  starter:    { capacity: 600,   window: 60 },  // 600/min
  business:   { capacity: 6000,  window: 60 },  // 6000/min
  enterprise: { capacity: 60000, window: 60 },  // 60000/min
};

function getLimits(req) {
  const plan = req.user?.plan || 'free';
  return RATE_LIMITS[plan];
}

You can also apply separate limits per endpoint — write endpoints get a lower limit than read endpoints:

const ENDPOINT_LIMITS = {
  'GET /api/users':    { capacity: 100, window: 60 },
  'POST /api/users':   { capacity: 10,  window: 60 },
  'DELETE /api/users':  { capacity: 5,   window: 60 },
};

Client-side: handling 429 responses

Good API clients respect rate limits. Implement exponential backoff with jitter:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status !== 429) return response;

    // Use Retry-After header if available
    const retryAfter = response.headers.get('Retry-After');
    const waitMs = retryAfter
      ? parseInt(retryAfter, 10) * 1000
      : Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 30000);

    console.warn(`Rate limited. Retrying in ${waitMs}ms (attempt ${attempt + 1})`);
    await new Promise(resolve => setTimeout(resolve, waitMs));
  }

  throw new Error('Max retries exceeded due to rate limiting');
}

Common mistakes

  1. Rate limiting by IP only — shared IPs (offices, VPNs) punish innocent users. Use API keys when possible.
  2. No headers — clients cannot self-regulate without RateLimit-Remaining.
  3. Generic 429 body — always tell clients when they can retry.
  4. In-memory counters in a cluster — each server tracks separately, so the effective limit is limit * server_count.
  5. No rate limiting on auth endpoints — login and password reset endpoints are prime targets for brute force.

Summary

Rate limiting is not optional for any API exposed to the internet. Start with a token bucket backed by Redis, return the standard RateLimit-* headers on every response, and give clients a clear Retry-After value when they hit the limit. Layer tiered limits by plan and per-endpoint limits by operation cost, and your API will stay healthy under pressure.