aaditya-DTU/rate-limiter: Three Algorithms, One Redis Lockstep, and the Real Cost of Getting Rate Limits Right

A compact Node.js library that turns rate limiting into a distributed systems lesson, showing how fixed windows, sliding logs, and token buckets behave when every request fights for the same key.

8 min read • View on GitHub • More from aaditya-DTU

A wide Redis vault stands at the center of a blank white field while three distinct request streams converge on it. One path hits a hard-edged counter, one path feeds a dense ledger of timestamps, and one path enters a bucket chamber with a slow refill valve. The scene explains that the repository is really about coordinating different limiting strategies against one shared distributed truth.
Three strategies, one shared key. The point is not just limiting traffic, but doing it atomically when many requests arrive together.
Key Takeaways

Most rate limiter libraries stop at the API surface. This one stays inside the machinery. It forces all the interesting questions into the open: what happens when many requests hit the same key, how do you keep the decision atomic, and which algorithm gives you the least surprising failure mode?

Why This Rate Limiter Is a Stress Test, Not Just Middleware

The most revealing part of aaditya-DTU/rate-limiter is not the Express wrapper. It is the decision to hammer one shared Redis key under load so the limiter has to prove it can survive contention. That turns the project into a correctness test disguised as a convenience library.

The benchmark setup matters because rate limiting is only easy when traffic is calm. Under bursty load, the problem becomes global state management: every request wants the same answer, and the answer has to be right even when multiple instances are asking at once.

One Redis key becomes a global decision because the whole check, update, and expiry path runs atomically inside Lua.

// shared-key load test idea
const sharedKeyGenerator = () => 'benchmark:shared-key';

app.get('/benchmark', rateLimiter({
  strategy: 'tokenBucket',
  keyGenerator: sharedKeyGenerator,
  failClosed: true
}), handler);

Three Strategies, Three Very Different Trade-offs

The repo implements three answers to the same question. Each one protects the same API, but each one pays a different price in memory, burst tolerance, and boundary behavior.

StrategyAccuracyMemory costBurst toleranceBoundary fairnessOperational complexity
Fixed WindowLow to mediumLowHigh at window edgesWeakLow
Sliding Window LogHighHighLow to mediumStrongMedium
Token BucketMediumLow to mediumHighGoodMedium
A close-up visual split shows three rate limiting behaviors in motion. On one side, a cliff-like counter jumps at a window boundary. In the middle, a smooth gate trims a growing and shrinking line of timestamp markers. On the other side, a reservoir slowly refills and releases bursts of tokens. The scene explains the trade-off between precision, memory use, and burst handling.
The algorithms are not interchangeable. They make different promises about fairness, memory, and how much burst traffic they will absorb.

Fixed window is the cheapest to run, but it can let a burst slip through at the seam between windows. Sliding window log is the strictest, because it tracks every request timestamp. Token bucket sits in the middle: it remembers enough state to feel smooth without storing a full log of traffic history.

The Real Trick Is Atomicity in Redis

The algorithms matter, but Lua matters more. Each strategy runs its check and mutation inside Redis as one atomic operation, so the limiter never has to trust a separate read and write cycle on the application side. That removes the race condition that would otherwise appear the moment two requests arrive together.

Without atomic execution, one request can inspect stale state while another request updates it. The result is the classic distributed bug: the limiter works almost all of the time, until load makes the edge cases visible.

local now = tonumber(ARGV[1])
local lastRefill = tonumber(redis.call('HGET', key, 'lastRefill') or now)
local elapsedSeconds = math.max(0, (now - lastRefill) / 1000)
local refillAmount = elapsedSeconds * refillRate
local tokens = math.min(capacity, (tonumber(redis.call('HGET', key, 'tokens') or capacity)) + refillAmount)
redis.call('HSET', key, 'tokens', tokens, 'lastRefill', now)

That refill logic shows why token bucket feels smooth. It does not need a background job. It recomputes the bucket level lazily when a request arrives, which keeps idle systems cheap and burst handling intuitive.

What the Lua boundary buys you

Why Token Bucket Feels Smooth and Sliding Window Feels Exact

Token bucket is the friendliest algorithm for bursty clients. It lets idle time accumulate into future capacity, which means traffic can surge without immediately hitting a hard wall. Sliding window log does the opposite. It is exact about who used what and when, but that precision costs memory and bookkeeping.

BehaviorToken BucketSliding Window Log
Idle periodRefills capacity over timeStores timestamps but gains no free burst credit
Burst trafficAbsorbs a controlled surgeRejects once the recent log is full
Memory footprintCompact stateGrows with recent traffic
Mental modelReservoir with a valveExact queue of recent events

That is the core design tension the repo exposes. Accuracy is not free. Fairness is not free. Even the friendliest limiter still has to decide how much state it is willing to carry for the sake of precision.

The Middleware Is a Policy Layer, Not Just Glue

The middleware in src/middleware/rateLimiter.js turns the strategy layer into a product decision. It standardizes the headers, hides the Redis plumbing, and exposes a blunt but important toggle: fail closed or fail open.

That choice is not a footnote. If Redis disappears, do you protect the API by blocking traffic, or preserve availability by letting traffic through? The repo makes that trade-off explicit, which is exactly what production code should do.

Failure modeFail closedFail open
Redis unavailableReject requestsAllow requests
Risk profileProtects the upstream serviceProtects availability
Best fitSecurity-sensitive endpointsAvailability-sensitive endpoints
Operational feelConservativePermissive
const limiter = createRateLimiter({
  strategy: 'slidingWindowLog',
  limit: 100,
  windowMs: 60000,
  failClosed: false
});

That is the most useful kind of middleware. It does not pretend policy is separate from implementation. It surfaces the policy because the policy is the application.

What Makes This Repo Worth Studying

This project is smaller and clearer than the big-name alternatives, which is exactly why it is useful. It does not hide the algorithm under a forest of defaults. It shows the moving parts, the Redis cost, and the failure behavior in plain sight.

ProjectStrengthTrade-off
aaditya-DTU/rate-limiterReadable, auditable, educationalLess feature-rich than enterprise libraries
express-rate-limitWidely adopted, easy to useAbstracts away more of the mechanism
Bucket4jPrecise token bucket semanticsLives in a different language ecosystem

If you want a black box, this is not the one. If you want to understand how distributed rate limiting behaves under pressure, this repo is a clean place to start. It shows that the hard part is not naming the algorithm. It is making the algorithm correct when every request arrives at the same door.