This application implements 5 different rate limiting algorithms as Express middlewares.
- Token Bucket - Allows bursts of traffic up to bucket size
- Leaking Bucket - Queues requests and processes at fixed rate
- Fixed Window - Counts requests in fixed time windows
- Sliding Window Log - Tracks timestamps for precise rate limiting
- 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.
Implementation: middleware/tokenBucket.js · Config: config/rateLimiterConfig.js → tokenBucket · Endpoint: /api/token-bucket
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"]
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.
- 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
refillRatetokens perrefillInterval, so capacity planning downstream (e.g. sizing a database connection pool) is predictable. - Decoupled burst size from sustained rate.
bucketSizeandrefillRateare 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.
- Two parameters to tune, and they interact. Set
bucketSizetoo 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 →setagainst Redis is not atomic; two concurrent requests from the same client can both read the sametokensvalue and both be allowed, over-admitting by one request under heavy concurrency. Fixing it needs a Lua script orWATCH/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-Remainingon every response.
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
EVALinstead ofGET+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.
PEXPIREsized 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-limitwith a Redis store) before hand-rolling this — they've already solved clock skew across app servers, cluster-mode Redis key hashing, and script reloading onNOSCRIPTerrors, which a hand-rolled version has to get right too.
Implementation: middleware/leakingBucket.js · Config: config/rateLimiterConfig.js → leakingBucket · Endpoint: /api/leaking-bucket
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
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.
- 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.
queueSizeis a literal count of requests in flight, andprocessIntervalis 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.
- 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
processQueuegets 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.
processQueuekeeps rescheduling itself viasetTimeout/setIntervalper 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."
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
EVALremoves the check-then-push race the demo'slLen→rPushsequence has under concurrent requests from the same client. PEXPIREscaled to drain time reclaims idle clients' keys automatically.
Implementation: middleware/fixedWindow.js · Config: config/rateLimiterConfig.js → fixedWindow · Endpoint: /api/fixed-window
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"]
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
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.
- Cheapest algorithm here, by far. One Redis
INCR+ one conditionalEXPIREper 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
EXPIREset 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.
- The boundary-burst problem is real, not theoretical, and gets worse as
maxRequestsgrows — an attacker who knows (or can observe viaRetry-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-Aftercan 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.
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
EVALinstead ofINCR+EXPIRE. Removes the crash-between-calls case where a key ends up permanently without a TTL, quietly counting forever instead of resetting each window. PTTLread back in the same script gives an exactRetry-Afterinstead of recomputingwindowSize - (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.
Implementation: middleware/slidingWindowLog.js · Config: config/rateLimiterConfig.js → slidingWindowLog · Endpoint: /api/sliding-window-log
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"]
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.
- 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-Afteris 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.
- Memory cost scales with
maxRequestsper 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 highmaxRequestsand 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) plusEXPIRE— four Redis round-trips' worth of work versus fixed window's oneINCR. - Doesn't scale well to very high limits. A
maxRequestsof 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.
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
EVALmakes 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: 10000means 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).
Implementation: middleware/slidingWindowCounter.js · Config: config/rateLimiterConfig.js → slidingWindowCounter · Endpoint: /api/sliding-window-counter
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"]
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.
- 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 nowsubWindowSizetimes 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 + oneHINCRBY— 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.
- 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
windowSizeandmaxRequests, you now also picksubWindowSize— 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
HDELfor 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."
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
EVALmakes the scan-cleanup-increment sequence atomic — the exact race the demo's separateHGETALL/loop/HINCRBYcalls 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
HDELit. HKEYS+ loop scales withsubWindowCount, 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.
- Node.js (v14+)
- Redis (v6+)
npm install# macOS with Homebrew
brew services start redis
# Linux
sudo systemctl start redis
# Docker
docker run -d -p 6379:6379 redisnpm start
# or with auto-reload
npm run devReal-time dashboard: http://localhost:3000
node test.js/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
Edit config/rateLimiterConfig.js to adjust rate limiting parameters.
MIT
Afriduzzaman