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.
- This repo treats rate limiting as a concurrency problem first and a middleware feature second.
- Its real lesson is that correctness depends on atomic Redis execution, not just on picking a popular algorithm.
- Fixed window, sliding window log, and token bucket each buy a different balance of memory, burst tolerance, and fairness.
- The fail-open versus fail-closed choice turns the limiter into a product policy, not a code detail.
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.
// 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.
| Strategy | Accuracy | Memory cost | Burst tolerance | Boundary fairness | Operational complexity |
|---|---|---|---|---|---|
| Fixed Window | Low to medium | Low | High at window edges | Weak | Low |
| Sliding Window Log | High | High | Low to medium | Strong | Medium |
| Token Bucket | Medium | Low to medium | High | Good | Medium |
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
- A single request sees one authoritative state transition.
- Expired data can be cleaned up before the next decision is made.
- Headers can be generated from the exact outcome of the mutation, not from a guessed replica of state.
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.
| Behavior | Token Bucket | Sliding Window Log |
|---|---|---|
| Idle period | Refills capacity over time | Stores timestamps but gains no free burst credit |
| Burst traffic | Absorbs a controlled surge | Rejects once the recent log is full |
| Memory footprint | Compact state | Grows with recent traffic |
| Mental model | Reservoir with a valve | Exact 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 mode | Fail closed | Fail open |
|---|---|---|
| Redis unavailable | Reject requests | Allow requests |
| Risk profile | Protects the upstream service | Protects availability |
| Best fit | Security-sensitive endpoints | Availability-sensitive endpoints |
| Operational feel | Conservative | Permissive |
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.
| Project | Strength | Trade-off |
|---|---|---|
| aaditya-DTU/rate-limiter | Readable, auditable, educational | Less feature-rich than enterprise libraries |
| express-rate-limit | Widely adopted, easy to use | Abstracts away more of the mechanism |
| Bucket4j | Precise token bucket semantics | Lives 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.





