Rate Limiting Algorithms with Redis


redis distributed-systems api-design backend security

Why do we need?

•	Prevents overload → avoids crashes during traffic spikes
•	Stops abuse → protects against bots, brute force, DDoS
•	Ensures fairness → one user can’t hog all resources
•	Controls cost → limits excessive resource usage
•	Stabilizes performance → smoother response times

👉 In short: it keeps the system secure, stable, and fair

Lets talk about 3 widely used techniques

Fixed Window

Imagine a box that resets every minute. You get 10 tokens in that box. Every request uses one. Once empty, you’re blocked until the next minute starts.

Simple, but has a flaw: if you use all 10 in the last second of minute 1, and 10 more in the first second of minute 2, you’ve made 20 requests in 2 seconds - the window boundary can be gamed.

Sliding Window

Like Fixed Window, but instead of resetting on a clock tick, it looks backwards from right now. “How many requests have you made in the last 60 seconds?” No boundary exploit, but uses more memory (stores timestamps per request).

Token Bucket

You have a bucket that fills with tokens at a steady rate (say, 1 per second). Each request costs a token. If the bucket is full, new tokens are discarded. If empty, you wait. This is the most flexible - it allows short bursts while still enforcing a long-term rate.


Redis Data Structures Used

AlgorithmRedis StructureWhy
Fixed WindowString (counter)Just a number + TTL. Dead simple.
Sliding WindowSorted SetStores timestamps, auto-removes old ones.
Token BucketHashStores tokens + lastRefill together.

Trade-Off Comparison

Fixed WindowSliding WindowToken Bucket
Burst allowed?At boundary onlyNo - always strictYes - up to bucket capacity
Best forSimple internal APIsAPIs needing hard fairnessServices that tolerate short bursts
Penalizes idle users?NoNoNo - rewards them with saved tokens
ComplexityLowMediumMedium
Redis structureString + TTLSorted SetHash
Atomic by default?No - needs LuaNo - needs LuaNo - needs Lua
Memory usageVery lowHigh (stores all timestamps)Low (just 2 fields)
AccuracyLow - boundary exploit possibleHigh - no boundary gapsMedium - burst can skew rate

Fixed Window is the only one with a known exploit - the boundary burst problem. Sliding Window was invented to fix that flaw, and Token Bucket trades some accuracy for flexibility.


Which One Should You Use?

Simple rule - just ask one question: “Does it matter if someone sneaks in extra requests?”

  • If you just need something quick and simple → Fixed Window. Internal tools, admin dashboards, low-stakes APIs.

  • If fairness is critical, no exceptions → Sliding Window. Payment APIs, auth endpoints, anything where every extra request is a real problem.

  • If your users have natural idle periods → Token Bucket. Mobile apps, search, feed APIs. Users deserve a burst after being inactive.

Token Bucket is the safe default for most consumer-facing APIs because it feels fair to users without being dangerously loose.


Implementation


const Redis = require("ioredis");
const redis = new Redis();

Fixed Window

async function fixedWindow(userId, limit = 10, windowSec = 60) {
  const key = `fw:${userId}`;
  const current = await redis.incr(key);

  if (current === 1) {
    await redis.expire(key, windowSec);
  }

  const ttl = await redis.ttl(key);

  if (current > limit) {
    return {
      allowed: false,
      message: `Rate limit exceeded. Try again in ${ttl}s.`,
      count: current,
      limit,
      ttl,
    };
  }

  return {
    allowed: true,
    message: `Request allowed (${current}/${limit})`,
    count: current,
    limit,
    ttl,
  };
}

Sliding Window

async function slidingWindow(userId, limit = 10, windowMs = 60_000) {
  const key = `sw:${userId}`;
  const now = Date.now();
  const windowStart = now - windowMs;

  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, "-inf", windowStart);
  pipeline.zcard(key);
  pipeline.zadd(key, now, `${now}-${Math.random()}`);
  pipeline.expire(key, Math.ceil(windowMs / 1000));

  const results = await pipeline.exec();
  const count = results[1][1];

  if (count >= limit) {
    await redis.zremrangebyscore(key, now, now);
    return {
      allowed: false,
      message: `Rate limit exceeded. ${count} requests in last ${windowMs / 1000}s.`,
      count,
      limit,
    };
  }

  return {
    allowed: true,
    message: `Request allowed (${count + 1}/${limit})`,
    count: count + 1,
    limit,
  };
}

Token Bucket

async function tokenBucket(userId, capacity = 10, refillRate = 1) {
  const key = `tb:${userId}`;
  const data = await redis.hgetall(key);
  const now = Date.now() / 1000;

  let tokens = data.tokens ? parseFloat(data.tokens) : capacity;
  let lastRefill = data.lastRefill ? parseFloat(data.lastRefill) : now;

  const elapsed = now - lastRefill;
  const refilled = elapsed * refillRate;
  tokens = Math.min(capacity, tokens + refilled);
  lastRefill = now;

  if (tokens < 1) {
    await redis.hset(key, "tokens", tokens.toFixed(4), "lastRefill", lastRefill);
    await redis.expire(key, capacity * 2);
    const waitSec = ((1 - tokens) / refillRate).toFixed(2);
    return {
      allowed: false,
      message: `No tokens left. Wait ~${waitSec}s for next token.`,
      tokens: parseFloat(tokens.toFixed(4)),
      capacity,
    };
  }

  tokens -= 1;
  await redis.hset(key, "tokens", tokens.toFixed(4), "lastRefill", lastRefill);
  await redis.expire(key, capacity * 2);

  return {
    allowed: true,
    message: `Request allowed. Tokens remaining: ${tokens.toFixed(2)}`,
    tokens: parseFloat(tokens.toFixed(2)),
    capacity,
  };
}

Demo

async function demo() {
  const userId = "user_anshuman";

  console.log("=== Fixed Window ===");
  for (let i = 0; i < 12; i++) {
    const result = await fixedWindow(userId, 5, 60);
    console.log(`Request ${i + 1}:`, result.message);
  }

  console.log("\n=== Sliding Window ===");
  for (let i = 0; i < 7; i++) {
    const result = await slidingWindow(userId, 5, 60_000);
    console.log(`Request ${i + 1}:`, result.message);
  }

  console.log("\n=== Token Bucket ===");
  for (let i = 0; i < 12; i++) {
    const result = await tokenBucket(userId, 5, 1);
    console.log(`Request ${i + 1}:`, result.message);
  }

  redis.disconnect();
}

demo().catch(console.error);

Watch Out: The Race Condition

One subtle race condition to be aware of - if your process crashes between incr and expire, the key gets created but never gets an expiry. The production fix is a Lua script:

-- KEYS[1] = rate limit key, e.g. "fw:user123"
-- ARGV[1] = limit
-- ARGV[2] = window in seconds

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call("INCR", key)

if current == 1 then
  redis.call("EXPIRE", key, window)
end

local ttl = redis.call("TTL", key)

local allowed = 0
if current <= limit then
  allowed = 1
end

return { allowed, current, limit, ttl }

Node.js example with EVAL:


async function fixedWindow(userId, limit = 10, windowSec = 60) {
  const key = `fw:${userId}`;

  const [allowedRaw, count, max, ttl] = await redis.eval(
    luascript,
    1,
    key,
    limit,
    windowSec
  );

  const allowed = allowedRaw === 1;

  return {
    allowed,
    message: allowed
      ? `Request allowed (${count}/${max})`
      : `Rate limit exceeded. Try again in ${ttl}s.`,
    count,
    limit: max,
    ttl,
  };
}

Comments