All articles
Tutorials

Rate Limiting Algorithms: Code Examples and Comparison

Compare token bucket, sliding window, and fixed window rate limiting, then implement each algorithm in Node.js with Redis.

Rate Limiting Algorithms: Code Examples and Comparison cover
16 min read

TL;DR

  • Token bucket, fixed window, and sliding window are not interchangeable. Each algorithm makes a different tradeoff between accuracy, memory cost, and burst behavior.
  • Fixed window is cheap but leaks at boundaries. Token bucket allows intentional burst, which is also its failure mode. Sliding window log is the most accurate but the most expensive.
  • Sliding window counter (the Cloudflare hybrid) is what most production systems actually use.
  • The previous article in this series covered exponential backoff as the client-side answer to unreliable delivery. This is the server-side answer.

Two APIs can set identical request limits and behave completely differently under burst traffic or distributed retries, depending on the algorithm each uses. This article covers four rate limiting algorithms (fixed window, token bucket, sliding window log, and sliding window counter) with Node.js and Redis implementations for each, and a decision framework for choosing between them.

A fintech API, a flash sale, and 800 simultaneous 429s

A payments API ships token bucket rate limiting. It works fine at the expected 100 requests per minute. Then a flash sale hits. 800 clients all retry after a shared payment timeout, which is exactly what exponential backoff does when the base delay is the same across all clients. Every client fires at the same second. The token bucket empties instantly. Every client receives a 429.

The bucket refills 60 seconds later. All 800 clients, still using the same exponential backoff base, retry at the same time. Another 429 storm. The cycle repeats.

Token bucket didn't cause this. But it didn't stop it either. A sliding window counter would have absorbed the burst differently. This article explains why and gives you the implementation for all four algorithms so you can make the choice deliberately, not by accident.

What the algorithm choice controls

The naive framing is that rate limiting means setting a number. 100 requests per minute. Done. The real problem is that two APIs with an identical limit but different algorithms behave completely differently under burst traffic, distributed retries, or coordinated load.

The algorithm choice controls three things:

  • How burst is handled: does the limit smooth traffic or does it allow front-loading?
  • Where the accuracy boundary is: can a client legally double their effective rate by straddling a window reset?
  • What it costs per user in Redis: O(1) for counters, O(requests) for log-based approaches.

Getting this wrong doesn't just mean some users get 429s they shouldn't. It means clients who understand your algorithm can exploit boundary behavior while legitimate users under retry load get blocked.

Note

What you'll need: Node.js 18+, Redis 7+ reachable from your API service, and Express 4+ or 5+ in your project.

Use one shared Redis connection module and import it across your middleware.

lib/redis.ts
import { createClient } from "redis";
 
export const redis = createClient({
  url: process.env.REDIS_URL ?? "redis://localhost:6379",
});
 
redis.on("error", (err) => console.error("Redis client error:", err));
 
await redis.connect();

Note

All examples use node-redis v4, the client currently maintained by Redis Labs. If your codebase uses ioredis, the Redis commands are identical; only the client instantiation differs.

Fixed window: the default most teams ship

Divide time into discrete windows (every 60 seconds, every minute, every hour). Each window gets a counter. When a request arrives, increment the counter. If it exceeds the limit, reject the request. At the start of the next window, the counter resets.

Fixed window rate limiting: timeline showing discrete 60-second windows, each with independent counters that reset at window boundaries

Here is the Express middleware implementation:

middleware/fixed-window.ts
import { redis } from "../lib/redis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
export async function fixedWindow(
  userId: string,
  limit: number,
  windowSecs: number
): Promise<RateLimitResult> {
  const windowStart = Math.floor(Date.now() / 1000 / windowSecs) * windowSecs;
  const key = `rate:fixed:${userId}:${windowStart}`;
 
  const count = await redis.incr(key);
 
  // Set TTL atomically to prevent keys without expiration
  await redis.expire(key, windowSecs, "NX");
 
  const resetAt = windowStart + windowSecs;
 
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetAt,
  };
}

The boundary exploit. Consider a limit of 100 requests per minute, and a client who sends 100 requests at 11:59:55 (inside the current window, allowed) then 100 requests at 12:00:05 (inside the next window, also allowed). Both batches pass. The client has sent 200 requests in 10 seconds against a "100 per minute" limit. The window reset at 12:00:00 effectively doubled their burst capacity.

This is not a bug in the implementation. It is a property of the algorithm. If your traffic is uniform and your clients are not adversarial, fixed window is fine: it is cheap, predictable, and easy to explain. If your clients are retry-heavy or your API is public, the boundary burst is a real exploit.

Use fixed window when: your traffic is internal, predictable, and clients are not coordinating retries.

Token bucket: controlled burst

Imagine a bucket that holds tokens. Tokens refill at a constant rate (e.g., 10 tokens per second). Each request consumes one token. If the bucket has tokens, the request is allowed. If it's empty, the request is rejected. The bucket has a maximum capacity, and excess tokens are discarded.

The burst behavior is the defining feature: if a client has been quiet for 10 seconds and the refill rate is 10 tokens/sec, they've accumulated up to capacity tokens and can fire them all at once.

Token bucket rate limiting: bucket holds tokens that refill at constant rate; requests consume tokens; burst allowed up to capacity

The naive Redis implementation reads the token count and last refill time, calculates the new count, then writes it back. Under concurrent requests, two workers can read the same stale count, both approve a request, and you've allowed twice the intended traffic. This is a real race condition that surfaces under load.

The solution is an atomic read-modify-write using a Lua script. Redis executes Lua scripts as a single atomic operation, so no other command can execute between the read and the write.

middleware/token-bucket.ts
import { redis } from "../lib/redis";
 
// Lua script runs atomically; no race condition between read and write
const TOKEN_BUCKET_SCRIPT = `
local key        = KEYS[1]
local capacity   = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])   -- tokens per millisecond
local now        = tonumber(ARGV[3])   -- current time in ms
 
local data      = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens    = tonumber(data[1]) or capacity
local lastRefill = tonumber(data[2]) or now
 
-- Continuous refill: tokens += elapsed * rate
-- This prevents the thundering herd caused by discrete tick-based refills
local elapsed     = math.max(0, now - lastRefill)
local tokensToAdd = elapsed * refillRate
tokens = math.min(capacity, tokens + tokensToAdd)
 
-- Consume one token if available
if tokens >= 1 then
  tokens = tokens - 1
  redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
  redis.call('PEXPIRE', key, math.ceil(capacity / refillRate))
  return {1, math.floor(tokens)}
else
  return {0, 0}
end
`;
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
export async function tokenBucket(
  userId: string,
  capacity: number,
  refillPerSecond: number
): Promise<RateLimitResult> {
  const refillPerMs = refillPerSecond / 1000;
  const now = Date.now();
  const key = `rate:token:${userId}`;
 
  const result = (await redis.eval(TOKEN_BUCKET_SCRIPT, {
    keys: [key],
    arguments: [
      String(capacity),
      String(refillPerMs),
      String(now),
    ],
  })) as [number, number];
 
  const tokensRemaining = result[1];
  const timeToFullRefill = Math.ceil((capacity - tokensRemaining) / refillPerSecond);
  const resetAt = Math.floor(now / 1000) + timeToFullRefill;
 
  return {
    allowed: result[0] === 1,
    remaining: result[1],
    resetAt,
  };
}

Warning

The continuous refill line is not optional. Discrete tick-based refills (refilling all tokens at the start of each second) cause a thundering herd. When 10,000 clients are queued against an empty bucket, a tick-based refill releases all of them simultaneously at the second boundary, slamming downstream services. Continuous refill (tokens += elapsed * rate on every request check) spreads arrivals naturally.

The burst tradeoff. If capacity === limit, a client can legally fire the full limit in milliseconds, wait for a full refill, then fire again. For a 100 request/minute limit with capacity 100, that means 100 requests in 100ms, then another 100 sixty seconds later. If that's not the traffic shape you want to allow, reduce burst capacity to 20-30% of the limit, or switch to a sliding window.

Use token bucket when: you want to explicitly allow controlled bursts, for example, an API that serves batch export operations where clients legitimately need to fire multiple requests in sequence.

Sliding window log: exact but expensive

Instead of counters, store a timestamp for every request. When a new request arrives, remove all timestamps older than the window, count what remains, and reject if the count exceeds the limit. The window slides with time, so there is no reset boundary to exploit.

Sliding window log rate limiting: sorted set of request timestamps; window slides continuously; old entries pruned; no boundary burst

The middleware uses a Redis sorted set where each member is a timestamped request ID:

middleware/sliding-window-log.ts
import { redis } from "../lib/redis";
import { randomUUID } from "crypto";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
  oldestRequestAt: number | null;
}
 
export async function slidingWindowLog(
  userId: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
  const key = `rate:sliding:log:${userId}`;
 
  // Use unique value to prevent concurrent requests from overwriting each other
  const uniqueValue = `${now}:${randomUUID()}`;
 
  const pipeline = redis.multi();
  pipeline.zRemRangeByScore(key, 0, windowStart);
  pipeline.zCard(key);
  pipeline.zAdd(key, { score: now, value: uniqueValue });
  pipeline.pExpire(key, windowMs);
 
  const results = await pipeline.exec();
  const currentCount = (results[1] as number) + 1;
 
  const resetAt = Math.ceil((now + windowMs) / 1000);
 
  if (currentCount > limit) {
    await redis.zRem(key, uniqueValue);
 
    const oldest = await redis.zRange(key, 0, 0, { BY: "RANK" });
    return {
      allowed: false,
      remaining: 0,
      resetAt,
      oldestRequestAt: oldest.length > 0 ? Number(oldest[0].split(":")[0]) : null,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - currentCount,
    resetAt,
    oldestRequestAt: null,
  };
}

The memory cost. Every request that hasn't aged out of the window is a member of the sorted set. For a user who sends 100 requests per minute with a 60-second window, that's 100 members in their set at all times. For a user sending 10,000 requests per minute, that's 10,000 members. At 100,000 active users each making 1,000 requests per minute, you're looking at 100 million sorted set members. That's the real constraint.

Use sliding window log when: you need exact rate limiting and memory cost is not a constraint, for example, internal APIs or high-value B2B integrations with small user counts.

Sliding window counter: the production default

In 2017, Cloudflare published a description of how they implemented rate limiting at scale across millions of domains. The insight was simple: you do not need a full request log to approximate sliding window behavior. Two counters and one calculation get you within 0.003% of the exact count.

Sliding window counter rate limiting: two fixed windows with weighted calculation; previous window fades as current window progresses; O(1) memory cost

This weights the previous window's count by how much of it still overlaps with the current sliding window. As you move further into the current window, the previous window contributes less. The approximation degrades to at most ~0.003% error in the worst case, which is negligible for practical rate limiting.

middleware/sliding-window-counter.ts
import { redis } from "../lib/redis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
export async function slidingWindowCounter(
  userId: string,
  limit: number,
  windowSecs: number
): Promise<RateLimitResult> {
  const now = Date.now() / 1000; // seconds
  const windowStart = Math.floor(now / windowSecs) * windowSecs;
  const prevWindowStart = windowStart - windowSecs;
 
  const currentKey = `rate:sw:${userId}:${windowStart}`;
  const prevKey    = `rate:sw:${userId}:${prevWindowStart}`;
 
  // Fetch both counters in one round trip
  const [rawCurr, rawPrev] = await redis.mGet([currentKey, prevKey]);
  const currentCount  = parseInt(rawCurr ?? "0", 10);
  const previousCount = parseInt(rawPrev ?? "0", 10);
 
  const elapsed = now - windowStart;
  const prevWeight = (windowSecs - elapsed) / windowSecs;
 
  // Weighted estimate of requests in the sliding window
  const estimated = currentCount + previousCount * prevWeight;
 
  if (estimated >= limit) {
    const resetAt = windowStart + windowSecs;
    return { allowed: false, remaining: 0, resetAt };
  }
 
  const pipeline = redis.multi();
  pipeline.incr(currentKey);
  pipeline.expire(currentKey, windowSecs * 2); // keep for two windows
 
  await pipeline.exec();
 
  const resetAt = windowStart + windowSecs;
  return {
    allowed: true,
    remaining: Math.max(0, Math.floor(limit - estimated - 1)),
    resetAt,
  };
}

O(1) memory per user, two counters always, four Redis ops per request, and accuracy indistinguishable from a true sliding window for any practical traffic pattern.

Use sliding window counter when: you need near-exact accuracy at O(1) memory cost. This is the right default for most production SaaS APIs.

Express middleware

All four implementations return the same shape. The middleware wraps whichever algorithm you choose.

Step 1: Create the rate-limit middleware

The middleware wraps whichever algorithm function you choose and sets the standard rate limit headers on every response:

middleware/rate-limit.ts
import type { Request, Response, NextFunction } from "express";
import { slidingWindowCounter } from "./sliding-window-counter";
 
interface RateLimitOptions {
  limit: number;
  windowSecs: number;
  keyFn?: (req: Request) => string;
}
 
export function rateLimit(options: RateLimitOptions) {
  const { limit, windowSecs, keyFn } = options;
 
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = keyFn
      ? keyFn(req)
      : (req.headers["x-api-key"] as string) ?? req.ip ?? "anonymous";
 
    const result = await slidingWindowCounter(key, limit, windowSecs);
 
    res.setHeader("X-RateLimit-Limit", limit);
    res.setHeader("X-RateLimit-Remaining", result.remaining);
    res.setHeader("X-RateLimit-Reset", result.resetAt);
 
    if (!result.allowed) {
      const retryAfter = Math.max(1, result.resetAt - Math.floor(Date.now() / 1000));
      res.setHeader("Retry-After", retryAfter);
 
      return res.status(429).json({
        error: {
          code: "rate_limit_exceeded",
          message: "Too many requests. Please retry after the reset window.",
          retry_after: retryAfter,
        },
      });
    }
 
    next();
  };
}

Step 2: Apply the middleware in your Express app

Register it globally and override it on individual routes that need tighter limits:

app.ts
import express from "express";
import { rateLimit } from "./middleware/rate-limit";
 
const app = express();
 
// Global limit: 100 requests per minute per API key
app.use(rateLimit({ limit: 100, windowSecs: 60 }));
 
// Tighter limit on expensive endpoints
app.post(
  "/api/export",
  rateLimit({
    limit: 5,
    windowSecs: 60,
    keyFn: (req) => `export:${req.headers["x-api-key"]}`,
  }),
  exportHandler
);

Algorithm comparison

AlgorithmAccuracyMemory per userBurst handlingRedis opsBest for
Fixed WindowLowO(1)Boundary burst allowed2Internal, low-risk APIs
Token BucketMediumO(1)Explicit burst capacity3 (Lua)Batch operations, controlled burst
Sliding Window LogHighO(requests)Exact, no burst3Internal APIs, small user base
Sliding Window CounterMedium-HighO(1)Approximate, very close4Most production SaaS APIs

What about leaky bucket?

Leaky bucket processes requests at a fixed outbound rate regardless of how they arrive, so requests queue up and drain at a constant pace. This is traffic shaping, not rate limiting.

Leaky bucket: requests queue and drain at constant rate; traffic shaping for downstream protection; queue-based smoothing

Token bucket is a policy on who is allowed in. Leaky bucket is a control on how fast you talk to downstream services. If you are protecting an API from clients, use token bucket or sliding window. If you are protecting a downstream service such as a database or a third-party API from your own application's burst writes, leaky bucket is the right primitive.

The decision framework

Three questions decide the algorithm:

1. Does the boundary burst matter? If your users are internal engineers, the boundary exploit is not a real threat. Fixed window is fine. If your API is public or handles payments, use sliding window counter.

2. Do you want to allow burst by design? If clients legitimately need to fire a batch of requests quickly (exports, bulk operations, data imports), token bucket with a configured burst capacity is the right choice. Set capacity to the maximum burst you want to allow, not the per-minute limit.

3. Do you need exact accuracy or is approximate good enough? If you are rate limiting against a financial or regulatory threshold where every request matters, use sliding window log and accept the memory cost. For everything else, the sliding window counter's ~0.003% approximation error is negligible.

For most SaaS APIs: sliding window counter is the right default. It has O(1) memory, near-exact accuracy, and the Cloudflare proof point.

Production notes

Rate limit by the right key. Rate limiting by IP is the weakest possible key. An attacker behind a CDN or a shared office network all looks like one IP. Choose the key that matches your threat model:

KeyWhen to use
Per API keyMost authenticated APIs
Per user IDWhen one user can have multiple API keys
Per endpoint + userHigh-value endpoints that need individual limits
Per IPUnauthenticated endpoints only (login, registration, password reset)

Always return Retry-After on a 429. Express it as integer seconds from now, not an HTTP date. The IETF rate limit headers draft specifies integer seconds specifically because it does not rely on clock synchronization and is resilient to clock skew. A 429 without Retry-After is a useless error for clients that implement polite retry behavior.

Warning

If you're running on Cloudflare Workers or Vercel Edge Functions, the Redis implementations in this article don't apply directly. Both platforms have native rate limiting primitives that operate at the edge; use those rather than adding a Redis round trip to every request.

When rate limiting is the wrong tool

Bad client, no backoff. If the real problem is a client retrying without any delay, rate limiting will not fix it; the client will hammer you until enough requests get through. The solution is fixing the client. The previous article on webhooks and retries covers exponential backoff with jitter from the client side.

One expensive endpoint. If a single endpoint is your bottleneck (a full-table scan, a slow external API call), rate limiting the whole API hides the problem instead of solving it. The right fix is per-endpoint limits with explicit resource quotas, or refactoring the expensive operation.

Protecting downstream services. If you're trying to protect a database from your own application's burst writes, rate limiting client requests is the wrong layer. Circuit breakers, which stop issuing requests to a downstream service when it starts failing, are the right pattern. Rate limiting restricts clients. Circuit breakers protect dependencies.

Share𝕏

Writer

  • Wale Bashir

    Technical content writer and full-stack engineer with experience across Web3, AI, and backend systems.

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
Rate Limiting Algorithms: Code Examples and Comparison | Reclear