Skip to content

About

Node/Express app implementing all 5 rate limiting algorithms (token bucket, leaking bucket, fixed/sliding window) with Redis, plus a live dashboard for testing them.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

Rate Limiter Application

GitHub LinkedIn

Overview

This application implements 5 different rate limiting algorithms as Express middlewares.

Algorithms

  1. Token Bucket - Allows bursts of traffic up to bucket size
  2. Leaking Bucket - Queues requests and processes at fixed rate
  3. Fixed Window - Counts requests in fixed time windows
  4. Sliding Window Log - Tracks timestamps for precise rate limiting
  5. Sliding Window Counter - Combines fixed windows with sliding window approximation

Each linked doc has a diagram and a detailed pros/cons breakdown tied to this repo's actual implementation. Full content inlined below.

Algorithm Deep Dives

Token Bucket

Implementation: middleware/tokenBucket.js · Config: config/rateLimiterConfig.js → tokenBucket · Endpoint: /api/token-bucket

Diagram

flowchart LR
    R["Refill worker<br/>+refillRate tokens<br/>every refillInterval"] --> Bucket
    Bucket(("Bucket<br/>max = bucketSize<br/>tokens"))
    Req["Incoming request"] --> Check{"tokens >= 1?"}
    Bucket --> Check
    Check -- "yes: tokens -= 1" --> Allow["200 OK<br/>X-RateLimit-Remaining: tokens"]
    Check -- "no" --> Deny["429<br/>Retry-After"]
Loading

This repo does the refill lazily (no background timer): on every request it computes tokensToAdd = floor((now - lastRefillTime) / refillInterval) * refillRate, tops the bucket up to bucketSize, then tries to spend one token. State lives in Redis as token_bucket:<clientIp>.

Current config: bucketSize: 10, refillRate: 1, refillInterval: 1000ms → up to 10 requests in a burst, then a steady 1 req/sec.

Pros

  • Bursts are allowed, not just averaged. A client that's been idle can spend its whole bucket at once (up to bucketSize), which matches how real traffic behaves — users don't call your API at a perfectly metronomic rate.
  • O(1) memory per client. Only two numbers stored (tokens, lastRefillTime), regardless of how many requests the client has made. Contrast with sliding window log, which stores one entry per request.
  • No background job needed. Refill is computed on read, so there's nothing to run when no traffic is coming in — the bucket doesn't "leak time" while idle.
  • Smooth long-run rate. Over any long interval the allowed throughput converges to exactly refillRate tokens per refillInterval, so capacity planning downstream (e.g. sizing a database connection pool) is predictable.
  • Decoupled burst size from sustained rate. bucketSize and refillRate are two independent knobs — you can allow a big burst (bucketSize: 100) while keeping a slow sustained rate (refillRate: 1), which fixed window can't express.

Cons

  • Two parameters to tune, and they interact. Set bucketSize too high and you've effectively allowed a DoS-sized burst; too low and legitimate bursty clients (page load firing 5 parallel requests) get throttled. There's no single "correct" value — it depends on downstream capacity.
  • Not fair across clients under contention. A client that hoards its bucket by staying idle can, the moment it wakes up, monopolize downstream capacity for one burst — fixed/sliding window schemes spread that more evenly because they cap a count, not a reservoir.
  • Read-modify-write race in a naive implementation. This repo's get → compute → set against Redis is not atomic; two concurrent requests from the same client can both read the same tokens value and both be allowed, over-admitting by one request under heavy concurrency. Fixing it needs a Lua script or WATCH/MULTI, which this implementation does not use — acceptable for a demo, not for a strict quota.
  • Client-side reasoning is harder. Because remaining capacity depends on when the client last called (refill is time-based, not request-count-based), a client can't infer "how many can I send right now" purely from a fixed schedule — it has to read X-RateLimit-Remaining on every response.

Production-Ready Implementation

The demo code's GET → compute → SET against Redis (three separate round trips) is not atomic — two concurrent requests can read the same tokens count and both get admitted. A production version does the whole refill-and-consume operation as a single Lua script, so Redis executes it atomically with no race window:

-- token_bucket.lua
-- KEYS[1]           bucket key
-- ARGV[1] capacity  max tokens (bucketSize)
-- ARGV[2] refillMs  tokens added per millisecond (refillRate / refillIntervalMs)
-- ARGV[3] now       current time in ms
-- ARGV[4] requested tokens this request needs (usually 1, lets you weight expensive endpoints higher)

local capacity  = tonumber(ARGV[1])
local refillMs  = tonumber(ARGV[2])
local now       = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local data   = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts     = tonumber(data[2])

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

local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + elapsed * refillMs)

local allowed = 0
if tokens >= requested then
  tokens = tokens - requested
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- bucket is irrelevant once it's been full for a while — let Redis reclaim it
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refillMs) + 1000)

return { allowed, tostring(tokens) }
// middleware/tokenBucketProd.js
const fs = require('fs');
const path = require('path');
const redisClient = require('../utils/redisClient');

const SCRIPT = fs.readFileSync(path.join(__dirname, 'token_bucket.lua'), 'utf8');

function tokenBucketMiddleware({ capacity = 10, refillPerSecond = 1, keyPrefix = 'rl:tb' } = {}) {
  const refillPerMs = refillPerSecond / 1000;

  return async (req, res, next) => {
    // route included in the key: a global per-IP bucket would let one hot
    // endpoint starve every other endpoint's quota for that client
    const key = `${keyPrefix}:${req.ip}:${req.method}:${req.baseUrl}${req.path}`;
    const now = Date.now();

    try {
      const [allowed, tokensLeft] = await redisClient.eval(SCRIPT, {
        keys: [key],
        arguments: [String(capacity), String(refillPerMs), String(now), '1'],
      });

      res.setHeader('X-RateLimit-Limit', capacity);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, Math.floor(tokensLeft)));

      if (!allowed) {
        const deficit = 1 - parseFloat(tokensLeft);
        res.setHeader('Retry-After', Math.max(1, Math.ceil(deficit / refillPerMs / 1000)));
        return res.status(429).json({ error: 'Too Many Requests' });
      }

      next();
    } catch (err) {
      // Redis outage: fail OPEN so a cache blip doesn't take the whole API down.
      // Flip to fail-closed (return 503 here instead) if the limiter is protecting
      // something that must never be over-admitted (billing, a paid third-party call).
      console.error('Token bucket rate limiter error:', err);
      next();
    }
  };
}

module.exports = tokenBucketMiddleware;

What changed from the demo and why it matters in production:

  • One EVAL instead of GET+SET. Closes the over-admission race under concurrent requests from the same client — Redis runs the whole script single-threaded.
  • Key includes the route, not just the client IP, so one expensive endpoint can't silently consume a client's entire budget for every other endpoint.
  • Explicit fail-open on Redis errors, logged rather than thrown — a rate limiter should rarely be the reason your API returns 500s. Pick fail-closed instead if the limiter is the only thing standing between a client and a metered paid upstream.
  • PEXPIRE sized to time-to-refill, not a fixed TTL, so idle clients' keys are reclaimed by Redis automatically without a separate cleanup job.
  • At real scale, reach for a maintained library (rate-limiter-flexible, express-rate-limit with a Redis store) before hand-rolling this — they've already solved clock skew across app servers, cluster-mode Redis key hashing, and script reloading on NOSCRIPT errors, which a hand-rolled version has to get right too.

Leaking Bucket

Implementation: middleware/leakingBucket.js · Config: config/rateLimiterConfig.js → leakingBucket · Endpoint: /api/leaking-bucket

Diagram

flowchart LR
    Req["Incoming request"] --> Full{"queue length<br/>>= queueSize?"}
    Full -- "yes" --> Deny["429<br/>Retry-After = processInterval"]
    Full -- "no" --> Push["rPush onto FIFO queue"]
    Push --> Allow["200 OK, request accepted"]
    Queue[["FIFO queue<br/>max = queueSize"]]
    Push --> Queue
    Worker["processQueue worker<br/>pops 1 item / processInterval"] --> Queue
Loading

This repo stores the queue as a Redis list leaking_bucket:<clientIp>. Accepting a request just means "there's room in the queue" — a background processQueue loop (one per client key, started lazily on first request) pops one entry every processInterval ms, which is what "leaks" the bucket at a fixed rate.

Current config: queueSize: 10, processInterval: 1000ms → at most 10 requests ever outstanding, drained one per second regardless of how bursty the input is.

Pros

  • Strictly smooth output rate. Unlike token bucket, leaking bucket doesn't let a burst through — it only lets a burst in. Whatever hits the backend is always paced at exactly one per processInterval, which is valuable when the downstream system (e.g. a third-party API with a hard rate cap) cannot tolerate any burst at all.
  • Simple mental model for capacity. queueSize is a literal count of requests in flight, and processInterval is a literal drain rate — both map directly onto real resources (queue memory, downstream QPS) with no derived math.
  • Reference implementation exists at scale (e.g. uber-go/ratelimit), so the algorithm is well understood and battle-tested outside this demo.
  • Protects the backend, not just the edge. Because output is paced rather than merely gated, a slow downstream dependency (DB, third-party API) never sees more than one request per interval, even if 1000 requests arrive in the same millisecond.

Cons

  • Bursts get delayed, not rejected — that can be worse for latency-sensitive clients. A request accepted into a full-ish queue may sit for several seconds before processQueue gets to it; the client sees a slow success instead of a fast 429, which can look like an outage rather than a rate limit.
  • No burst capacity at all. A perfectly legitimate spike (a client's own retry-once logic, or five parallel calls from one page load) gets serialized to one per second just like abuse traffic — token bucket handles this case far better.
  • This implementation's background worker is a leak-prone pattern. processQueue keeps rescheduling itself via setTimeout/setInterval per client key with no explicit cap on concurrently running workers; if a client key never fully drains under continuous traffic, that worker runs indefinitely, and workers are keyed by Redis key, not by process — a server restart loses track of "is a worker already running" until state is rebuilt from Redis.
  • Queue occupies memory proportional to queueSize, per client, and unlike token bucket's two-number state, must actually hold each queued request's context (here, just a timestamp — but a real implementation queuing actual request objects costs more).
  • Harder to reason about "why was I rejected." The response the client got depended on queue occupancy at arrival time, which is a function of everyone else's recent traffic too — much less transparent than "you sent request #11 in this window."

Production-Ready Implementation

The demo's real FIFO queue + per-key setTimeout worker is exactly the "leak-prone background worker" problem called out above — it doesn't survive a process restart cleanly and needs one live timer per active client key. Production leaky-bucket implementations almost always use virtual scheduling instead: track a single "water level" number that decays over time by math, not by a running timer. No worker, no queue contents, just two numbers per client, updated atomically:

-- leaking_bucket.lua
-- KEYS[1]          bucket key
-- ARGV[1] capacity max level (queueSize)
-- ARGV[2] leakMs   level drained per millisecond (1 / processIntervalMs)
-- ARGV[3] now      current time in ms

local capacity = tonumber(ARGV[1])
local leakMs   = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])

local data  = redis.call('HMGET', KEYS[1], 'level', 'ts')
local level = tonumber(data[1])
local ts    = tonumber(data[2])

if level == nil then
  level = 0
  ts = now
end

local elapsed = math.max(0, now - ts)
level = math.max(0, level - elapsed * leakMs)

local allowed = 0
if level < capacity then
  level = level + 1
  allowed = 1
end

redis.call('HMSET', KEYS[1], 'level', level, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / leakMs) + 1000)

return { allowed, tostring(level) }
// middleware/leakingBucketProd.js
const fs = require('fs');
const path = require('path');
const redisClient = require('../utils/redisClient');

const SCRIPT = fs.readFileSync(path.join(__dirname, 'leaking_bucket.lua'), 'utf8');

function leakingBucketMiddleware({ capacity = 10, processIntervalMs = 1000, keyPrefix = 'rl:lb' } = {}) {
  const leakPerMs = 1 / processIntervalMs;

  return async (req, res, next) => {
    const key = `${keyPrefix}:${req.ip}:${req.method}:${req.baseUrl}${req.path}`;
    const now = Date.now();

    try {
      const [allowed, level] = await redisClient.eval(SCRIPT, {
        keys: [key],
        arguments: [String(capacity), String(leakPerMs), String(now)],
      });

      res.setHeader('X-RateLimit-Limit', capacity);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, capacity - Math.ceil(level)));

      if (!allowed) {
        res.setHeader('Retry-After', Math.ceil(processIntervalMs / 1000));
        return res.status(429).json({ error: 'Too Many Requests' });
      }

      next();
    } catch (err) {
      console.error('Leaking bucket rate limiter error:', err);
      next(); // fail open — see token bucket doc for the fail-closed trade-off
    }
  };
}

module.exports = leakingBucketMiddleware;

What changed from the demo and why it matters in production:

  • No background worker, no request queue in Redis. The "level" is derived mathematically from elapsed time, the same trick the token bucket script uses — this is what makes leaky bucket implementable without a scheduler at all.
  • This trades "delay the request" for "reject immediately." The demo's real queue could hold an accepted request for a few seconds before processing it; this version answers synchronously in one Redis round trip. If you actually need requests held and processed later (not just admitted/rejected), that's a job-queue problem — reach for BullMQ (already Redis-backed) rather than hand-rolling delayed processing.
  • Single atomic EVAL removes the check-then-push race the demo's lLen → rPush sequence has under concurrent requests from the same client.
  • PEXPIRE scaled to drain time reclaims idle clients' keys automatically.

Fixed Window Counter

Implementation: middleware/fixedWindow.js · Config: config/rateLimiterConfig.js → fixedWindow · Endpoint: /api/fixed-window

Diagram

flowchart LR
    Req["Incoming request at time t"] --> Key["windowKey =<br/>fixed_window:client:floor(t / windowSize)"]
    Key --> Incr["INCR windowKey<br/>(EXPIRE on first hit)"]
    Incr --> Check{"count > maxRequests?"}
    Check -- "yes" --> Deny["429<br/>Retry-After = time left in window"]
    Check -- "no" --> Allow["200 OK<br/>X-RateLimit-Remaining"]
Loading

The classic edge-burst failure mode this algorithm is known for:

flowchart LR
    subgraph W1["Window 1 (0s-60s)"]
      direction LR
      a1["req"] --- a2["req"] --- a3["... up to 10 near t=59s"]
    end
    subgraph W2["Window 2 (60s-120s)"]
      direction LR
      b1["10 more reqs<br/>right at t=60s"]
    end
    W1 -.->|"counter resets to 0<br/>at the boundary"| W2
Loading

Up to 2 × maxRequests can land in a very short real interval straddling a window boundary (e.g. 10 at 59.9s + 10 at 60.1s = 20 requests in 200ms), even though each window individually respected its own limit.

Current config: windowSize: 60000ms, maxRequests: 10 → 10 requests per rolling minute-aligned window, keyed as fixed_window:<clientIp>:<windowIndex> with a Redis EXPIRE so old window keys clean themselves up.

Pros

  • Cheapest algorithm here, by far. One Redis INCR + one conditional EXPIRE per request — no sorted sets, no queues, no stored history. This is the one to reach for when the traffic pattern doesn't punish boundary bursts (e.g. internal service calls, not adversarial clients).
  • Self-cleaning state. The EXPIRE set on the first request of a window means old window keys vanish on their own — no cron job or manual cleanup needed, and memory usage is bounded to "one counter per active client per current window."
  • Trivial to explain to API consumers. "10 requests per minute, resets on the minute" is far easier to document and reason about than a rolling window's fuzzier guarantee.
  • Counter is a single atomic INCR, so unlike this repo's token bucket, there's no read-modify-write race — Redis guarantees the increment is correct under concurrency.

Cons

  • The boundary-burst problem is real, not theoretical, and gets worse as maxRequests grows — an attacker who knows (or can observe via Retry-After) when a window resets can time two bursts around it to get roughly double quota through.
  • No smoothing at all. All 10 requests can legally land in the first millisecond of a window, then nothing for the remaining ~60 seconds — bursty by design, which is fine for abuse-prevention but bad if you need a steady downstream load (leaking bucket is the better fit there).
  • Window alignment is global, not per-client-first-request. Every client shares the same window boundaries (floor(now / windowSize)), so all clients' windows reset in lockstep — if that reset also happens to line up with a traffic spike (e.g. top of the hour), the burst problem compounds across many clients simultaneously.
  • Retry-After can be misleading near the end of a window. A client rejected at 59.9s into the window is told to retry in ~0.1s, then can immediately fire its full quota again — the algorithm doesn't guarantee even spacing between windows.

Production-Ready Implementation

The demo already uses INCR, which is atomic — but it's still two round trips (INCR, then conditionally EXPIRE), and if the process crashes or the connection drops between them, that key is left with no TTL and leaks forever. A production version does both in one script:

-- fixed_window.lua
-- KEYS[1]         window key (already includes the window index)
-- ARGV[1] limit   maxRequests
-- ARGV[2] windowMs

local count = redis.call('INCR', KEYS[1])
if count == 1 then
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
local ttl = redis.call('PTTL', KEYS[1])

local allowed = 1
if count > tonumber(ARGV[1]) then
  allowed = 0
end

return { allowed, count, ttl }
// middleware/fixedWindowProd.js
const fs = require('fs');
const path = require('path');
const redisClient = require('../utils/redisClient');

const SCRIPT = fs.readFileSync(path.join(__dirname, 'fixed_window.lua'), 'utf8');

function fixedWindowMiddleware({ limit = 10, windowMs = 60000, keyPrefix = 'rl:fw' } = {}) {
  return async (req, res, next) => {
    const now = Date.now();
    const windowIndex = Math.floor(now / windowMs);
    const key = `${keyPrefix}:${req.ip}:${req.method}:${req.baseUrl}${req.path}:${windowIndex}`;

    try {
      const [allowed, count, ttlMs] = await redisClient.eval(SCRIPT, {
        keys: [key],
        arguments: [String(limit), String(windowMs)],
      });

      res.setHeader('X-RateLimit-Limit', limit);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - count));

      if (!allowed) {
        res.setHeader('Retry-After', Math.ceil(ttlMs / 1000));
        return res.status(429).json({ error: 'Too Many Requests' });
      }

      next();
    } catch (err) {
      console.error('Fixed window rate limiter error:', err);
      next();
    }
  };
}

module.exports = fixedWindowMiddleware;

What changed from the demo and why it matters in production:

  • One EVAL instead of INCR + EXPIRE. Removes the crash-between-calls case where a key ends up permanently without a TTL, quietly counting forever instead of resetting each window.
  • PTTL read back in the same script gives an exact Retry-After instead of recomputing windowSize - (now % windowSize) in JS, which drifts slightly from what Redis actually has set if the two clocks (app server vs. Redis server) aren't perfectly in sync.
  • If the edge-burst problem (see diagram above) is unacceptable for your use case, this is the one algorithm here where the fix isn't "make this implementation better" — it's "use sliding window counter or sliding window log instead," since the weakness is structural, not an implementation bug.

Sliding Window Log

Implementation: middleware/slidingWindowLog.js · Config: config/rateLimiterConfig.js → slidingWindowLog · Endpoint: /api/sliding-window-log

Diagram

flowchart LR
    Req["Incoming request at time t"] --> Trim["ZREMRANGEBYSCORE:<br/>drop entries older than t - windowSize"]
    Trim --> Count["ZCARD: count remaining entries"]
    Count --> Check{"count >= maxRequests?"}
    Check -- "yes" --> Deny["429<br/>Retry-After = oldest entry's<br/>expiry time - now"]
    Check -- "no" --> Add["ZADD t → t<br/>EXPIRE windowSize"]
    Add --> Allow["200 OK<br/>X-RateLimit-Remaining"]
Loading

Every accepted request's exact timestamp is stored in a Redis sorted set (sliding_window_log:<clientIp>), scored by the timestamp itself. On each request the log is trimmed to just the last windowSize ms, then the remaining count decides allow/deny — so the "window" continuously slides with real time instead of jumping at fixed boundaries.

Current config: windowSize: 60000ms, maxRequests: 10 → truly at most 10 requests in any rolling 60-second span, not just any calendar-aligned one.

Pros

  • The only algorithm here with zero boundary artifacts. Because the window is anchored to "now," not to a fixed clock boundary, the fixed-window edge-burst problem cannot happen — the limit holds for every possible 60-second span, not just minute-aligned ones.
  • Exact, not approximate. The count returned is the literal number of requests in the trailing window — no averaging, no interpolation, no probabilistic error, which matters for a strict contractual quota (e.g. a paid API tier).
  • Retry-After is precise. It's computed straight from the oldest surviving timestamp's expiry, so a client can back off to exactly the right moment instead of guessing.
  • Auditable. Because every request's timestamp is retained (until it ages out), the sorted set itself can double as a mini access log for that client's recent activity.

Cons

  • Memory cost scales with maxRequests per client, not O(1). Unlike token bucket or fixed window (two numbers, one counter), this stores one sorted-set entry per request currently inside the window. At high maxRequests and many clients, that's meaningfully more Redis memory — this is the classic trade this algorithm makes for exactness.
  • More expensive per request. Each hit does a range-delete (ZREMRANGEBYSCORE), a cardinality check (ZCARD), and potentially an insert (ZADD) plus EXPIRE — four Redis round-trips' worth of work versus fixed window's one INCR.
  • Doesn't scale well to very high limits. A maxRequests of 100,000/hour per user means up to 100,000 sorted-set members live at once for that single user — fine at this repo's demo scale (maxRequests: 10), not fine for a high-throughput API tier.
  • Same score for near-simultaneous requests is a Redis sorted-set edge case — score ties are broken lexicographically by member value, which is harmless here (member and score are both the timestamp string) but is a subtlety worth knowing if you ever change what's stored as the member.

Production-Ready Implementation

The demo has a real correctness bug worth calling out: it uses ZADD redisKey now now — the timestamp as both the score and the member. A sorted set can't hold two members with the same value, so two requests landing in the same millisecond silently collapse into one entry and the second request is under-counted. Production needs a member that's unique per request even when timestamps collide, and the trim-count-add sequence needs to be atomic:

-- sliding_window_log.lua
-- KEYS[1]          sorted-set key
-- ARGV[1] now       current time in ms
-- ARGV[2] windowMs
-- ARGV[3] limit     maxRequests
-- ARGV[4] member    unique per request, e.g. `${now}-${crypto.randomUUID()}`

local windowStart = ARGV[1] - ARGV[2]
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, windowStart)

local count = redis.call('ZCARD', KEYS[1])
local allowed = 0

if count < tonumber(ARGV[3]) then
  redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
  allowed = 1
  count = count + 1
end

local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local oldestScore = oldest[2] or ARGV[1]

return { allowed, count, oldestScore }
// middleware/slidingWindowLogProd.js
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const redisClient = require('../utils/redisClient');

const SCRIPT = fs.readFileSync(path.join(__dirname, 'sliding_window_log.lua'), 'utf8');

function slidingWindowLogMiddleware({ limit = 10, windowMs = 60000, keyPrefix = 'rl:swl' } = {}) {
  return async (req, res, next) => {
    const now = Date.now();
    const key = `${keyPrefix}:${req.ip}:${req.method}:${req.baseUrl}${req.path}`;
    const member = `${now}-${crypto.randomUUID()}`; // fixes the same-millisecond collision bug

    try {
      const [allowed, count, oldestScore] = await redisClient.eval(SCRIPT, {
        keys: [key],
        arguments: [String(now), String(windowMs), String(limit), member],
      });

      res.setHeader('X-RateLimit-Limit', limit);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - count));

      if (!allowed) {
        const retryAfterMs = Math.max(0, parseInt(oldestScore, 10) + windowMs - now);
        res.setHeader('Retry-After', Math.ceil(retryAfterMs / 1000));
        return res.status(429).json({ error: 'Too Many Requests' });
      }

      next();
    } catch (err) {
      console.error('Sliding window log rate limiter error:', err);
      next();
    }
  };
}

module.exports = slidingWindowLogMiddleware;

What changed from the demo and why it matters in production:

  • Unique member per request (${now}-${uuid}) fixes real under-counting: without it, simultaneous requests from a fast client (or any client behind a proxy batching calls) silently share one sorted-set slot.
  • Single EVAL makes trim → count → conditional-add atomic, closing the same class of race the other four algorithms have in their demo form.
  • Memory is still the fundamental cost here — no script fixes that a busy client with maxRequests: 10000 means 10000 live sorted-set members. If that's your scale, sliding window counter is the better production default; keep this one for cases where exactness genuinely matters more than memory (e.g. a strict billing quota).

Sliding Window Counter

Implementation: middleware/slidingWindowCounter.js · Config: config/rateLimiterConfig.js → slidingWindowCounter · Endpoint: /api/sliding-window-counter

Diagram

flowchart LR
    Req["Incoming request at time t"] --> Hash["HGETALL sub-window counts<br/>for this client"]
    Hash --> Sum["Sum counts whose timestamp<br/>>= t - windowSize<br/>(drop + HDEL the rest)"]
    Sum --> Check{"total >= maxRequests?"}
    Check -- "yes" --> Deny["429<br/>Retry-After from<br/>oldest live sub-window"]
    Check -- "no" --> Bump["HINCRBY current<br/>sub-window bucket"]
    Bump --> Allow["200 OK"]
Loading

This is fixed window's cheap counters, made smoother by chopping each window into subWindowSize smaller buckets (a Redis hash, sliding_window_counter:<clientIp>, field = sub-window start timestamp, value = count in that sub-window). A request sums every sub-window bucket that still falls inside the trailing windowSize, instead of trusting one single counter that resets all at once — approximating a true sliding window without storing every individual timestamp.

Current config: windowSize: 60000ms, maxRequests: 10, subWindowSize: 10 → the minute is split into 10 six-second buckets; a request's admitted count is the sum of whichever buckets are still inside the trailing 60 seconds.

Pros

  • Best cost/accuracy trade-off of the five. Memory is O(subWindowSize) per client — a handful of hash fields — not O(maxRequests) like sliding window log, yet it largely closes fixed window's edge-burst hole because the boundary is now subWindowSize times finer-grained.
  • Tunable precision vs. cost. Raising subWindowSize (more, smaller buckets) buys closer approximation to a true sliding window at the cost of a few more hash fields; lowering it degenerates smoothly back toward plain fixed window. No other algorithm here has this dial.
  • Cheap per-request work. One HGETALL + sum + one HINCRBY — no sorted-set range-deletes, no per-request timestamp storage.
  • This is what most production rate limiters (e.g. Cloudflare's, per public writeups) actually use, precisely because it scales to high request volumes where sliding window log's O(n) memory becomes a real cost.

Cons

  • Still an approximation, not exact. It assumes requests are evenly distributed within each sub-window when weighting older buckets near the boundary; a client that clusters all its traffic at the very start or end of a sub-window can still get slightly more or fewer requests through than a strict sliding log would allow.
  • One more parameter to choose. Beyond windowSize and maxRequests, you now also pick subWindowSize — too coarse and you're back to fixed window's burst problem, too fine and cost approaches sliding window log's per-timestamp overhead for little extra accuracy.
  • Cleanup work happens on the read path. This implementation calls HDEL for every stale field during the same request that's checking the limit, so a client that hasn't called in a while pays a small amount of extra work (and Redis round trips) clearing out its own old sub-windows on its next request.
  • More moving parts to reason about than fixed window — the "why was this rejected" answer is now "sum of several sub-window counts," not one visible number, which is a harder mental model to hand to API consumers than fixed window's "count in the current window."

Production-Ready Implementation

The demo's HGETALL → JS loop → conditional HINCRBY is three-plus round trips and not atomic — a second request can read the same pre-increment total before the first one writes back. Production needs the scan, cleanup, and increment done inside Redis:

-- sliding_window_counter.lua
-- KEYS[1]             hash key
-- ARGV[1] now         current time in ms
-- ARGV[2] windowMs
-- ARGV[3] limit       maxRequests
-- ARGV[4] subWindowMs windowMs / subWindowSize

local windowStart = ARGV[1] - ARGV[2]
local fields = redis.call('HKEYS', KEYS[1])
local total = 0

for _, f in ipairs(fields) do
  local ts = tonumber(f)
  if ts < windowStart then
    redis.call('HDEL', KEYS[1], f)
  else
    total = total + tonumber(redis.call('HGET', KEYS[1], f))
  end
end

local allowed = 0
if total < tonumber(ARGV[3]) then
  local bucket = tostring(math.floor(ARGV[1] / ARGV[4]) * ARGV[4])
  redis.call('HINCRBY', KEYS[1], bucket, 1)
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
  allowed = 1
  total = total + 1
end

return { allowed, total }
// middleware/slidingWindowCounterProd.js
const fs = require('fs');
const path = require('path');
const redisClient = require('../utils/redisClient');

const SCRIPT = fs.readFileSync(path.join(__dirname, 'sliding_window_counter.lua'), 'utf8');

function slidingWindowCounterMiddleware({
  limit = 10,
  windowMs = 60000,
  subWindowCount = 10,
  keyPrefix = 'rl:swc',
} = {}) {
  const subWindowMs = windowMs / subWindowCount;

  return async (req, res, next) => {
    const now = Date.now();
    const key = `${keyPrefix}:${req.ip}:${req.method}:${req.baseUrl}${req.path}`;

    try {
      const [allowed, total] = await redisClient.eval(SCRIPT, {
        keys: [key],
        arguments: [String(now), String(windowMs), String(limit), String(subWindowMs)],
      });

      res.setHeader('X-RateLimit-Limit', limit);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - total));

      if (!allowed) {
        res.setHeader('Retry-After', Math.ceil(subWindowMs / 1000));
        return res.status(429).json({ error: 'Too Many Requests' });
      }

      next();
    } catch (err) {
      console.error('Sliding window counter rate limiter error:', err);
      next();
    }
  };
}

module.exports = slidingWindowCounterMiddleware;

What changed from the demo and why it matters in production:

  • Single EVAL makes the scan-cleanup-increment sequence atomic — the exact race the demo's separate HGETALL/loop/HINCRBY calls leave open under concurrency.
  • Cleanup and counting happen in the same pass inside Redis instead of round-tripping every stale field back to Node just to decide whether to HDEL it.
  • HKEYS + loop scales with subWindowCount, not with request volume — this is still the algorithm's core advantage over sliding window log, preserved here since the number of hash fields is bounded regardless of how many requests the client sends.
  • This is the algorithm real rate limiters (Cloudflare's public writeups describe the same technique) use at scale specifically because this Lua script's cost is independent of request volume, only of subWindowCount.

Setup

Prerequisites

  • Node.js (v14+)
  • Redis (v6+)

Installation

npm install

Start Redis

# macOS with Homebrew
brew services start redis

# Linux
sudo systemctl start redis

# Docker
docker run -d -p 6379:6379 redis

Run Application

npm start
# or with auto-reload
npm run dev

Real-time dashboard: http://localhost:3000

Testing

node test.js

Endpoints

  • /api/token-bucket - Token Bucket algorithm
  • /api/leaking-bucket - Leaking Bucket algorithm
  • /api/fixed-window - Fixed Window algorithm
  • /api/sliding-window-log - Sliding Window Log algorithm
  • /api/sliding-window-counter - Sliding Window Counter algorithm

Configuration

Edit config/rateLimiterConfig.js to adjust rate limiting parameters.

License

MIT

Author

Afriduzzaman

GitHub LinkedIn

About

Node/Express app implementing all 5 rate limiting algorithms (token bucket, leaking bucket, fixed/sliding window) with Redis, plus a live dashboard for testing them.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages