Middleware
src/middleware.ts
- Take a key off the request
- Call limiter.consume(key)
- Set the rate-limit headers
- Block with 429, or carry on
Limitra · npm package · rate limiting
Every rate limiter makes you pick Redis or memory once, at startup. Limitra lets the running server change its mind, so a Redis outage stops being an outage.
A rate limiter has to count requests somewhere. Put the count in Redis and every server in the cluster agrees, but every request now costs a network round trip. Keep it in memory and it is free, but each server counts on its own, so the real limit is your limit times the number of servers.
Both are reasonable. The problem is that you pick one when you write the line of setup code, and the process is stuck with it for its whole life. The moment that choice is wrong is exactly the moment you cannot change it: Redis is slow or gone, and the limiter is still dutifully waiting on it.
When Redis stops answering, a limiter built on it has two options and both are bad. It can fail open and let everything through, which is how a struggling service gets finished off. Or it can fail closed and reject everything, which turns a database problem into a total outage.
Neither is a bug. It is what you get when the storage decision is made once and cannot be revisited.
Three passes at it, in the order they actually happened.
Explore
Tried the obvious fixes first
Realise
Every one of them still waits on Redis
Insight
Ask the server, not the config file
The fix was not a faster store. It was a later decision.
The fix is not a better algorithm. It is moving the choice from startup to per-request. If the limiter asks which strategy to use every time it is called, then a server under stress can answer differently from one that is idle.
That turns the pick-one problem into a policy question, and a policy question can have a good answer: use the accurate expensive path when you can afford it, and the cheap local path when you cannot.
The library deliberately does not decide when to switch. You hand it a function. Limitra owns the mechanism, you own the policy, because the right threshold depends on your traffic and nobody else can know it.
This is the entire thing. It is small on purpose: all it does is ask, then delegate.
export const createAdaptiveLimiter = (options: AdaptiveOptions): RateLimiter => {
const consume = async (key: string): Promise<RateLimitResult> => {
const strategyName = await options.selector(key);
const limiter = options.strategies[strategyName];
if (!limiter) {
throw new Error(`Strategy '${strategyName}' not found in adaptive limiter definitions.`);
}
return limiter.consume(key);
};
return { consume };
};Everything interesting sits either side of it. Underneath, algorithms and stores are separate pieces that mix freely: three algorithms, two stores, any combination. Above it, the selector is yours.
The path a single request takes, and where the decision happens.
Pulls an identifier off the request, IP by default, or whatever your key generator returns. Then it calls consume and waits.
src/middleware.ts
Your function runs. It gets the key, so it can answer differently per user if you want, and returns the name of a strategy.
src/adaptive.ts
The usual selector measures event loop lag: schedule a callback, see how late it runs. If the loop is behind, the process is already saturated and a network call is the last thing it needs.
src/utils/health.ts
Counts against the current and previous window and weighs them, so a burst on a window boundary does not slip through. Correct across the whole cluster.
src/algorithms/sliding-window.ts
A plain counter in a Map. Less accurate and local to the process, but it costs nothing and it cannot be taken away by a network problem.
src/algorithms/fixed-window.ts
Limit, remaining and reset are set either way, so a client cannot tell which path served it. The degrade is invisible from outside.
src/middleware.ts
The same request, down whichever lane the selector picked. Nothing above the store layer knows which one it got.
src/middleware.ts
src/adaptive.ts
src/utils/health.ts
healthy · normal
accurate, and it costs a round trip
Redis store
one limit, however many machines
struggling · panic
rough, and it costs nothing
Memory store
13ms through the whole outage
Token bucket cannot be done with ordinary Redis commands. Reading the bucket, working out the refill and writing it back is three round trips, and two servers doing that at once will both read the same tokens and both spend them.
So the whole calculation is a Lua script that Redis runs as one operation. Read the hash, add tokens for the time that has passed, cap at capacity, spend one if there is one, write it back, set a TTL sized to how long a full refill takes so idle keys clean themselves up.
Against the two libraries people actually use. Docker, Node 20 Alpine, shared Redis, k6 driving 60 concurrent users for 30 seconds at 100 requests per 60 seconds.
10.59ms
Limitra average
23.90ms at p95
8.84ms
rate-limiter-flexible
21.18ms at p95
2.7ms
What the abstraction costs
the price of being able to switch
Source · benchmarks.md, reproducible from the repo
Limitra is slower. Roughly 2.7ms slower at the tail than the fastest library tested, and that is the honest cost of routing every call through a decision instead of straight to a store. It is worth writing down rather than hiding, because the whole argument rests on what that 2.7ms buys.
The second test is the one that matters. Same setup, but ten seconds in, the Redis connection is severed. The client is configured the way a serious production service would be: no offline queue, one second command timeout. No hiding behind a buffer that quietly holds requests until Redis comes back.
The others
Limitra
Requests served
0 of 7,273
21,722 of 21,722
Success rate
0%
100%
Average latency
timed out
13ms
What happened
500s and timeouts
switched to memory on its own
Source · k6, 31 virtual users, 30.1s, 7,274 iterations
Both express-rate-limit and rate-limiter-flexible went to zero. Not degraded, zero: every request in the window failed. Limitra served all of them at 13ms, because the selector saw the unhealthy state and stopped routing to Redis without anyone touching it.
That is the trade the whole library exists to make. Give up 2.7ms in the good case to not have a bad case.
What I saw
What it meant
express-rate-limit failed the baseline test too, not just the outage one
A lot of libraries are only tested with the offline queue on. Turn it off and they were never really talking to Redis synchronously at all.
The adaptive limiter ended up being about twenty lines
The hard part was never the switching. It was making the algorithms and stores separate enough that switching was possible.
Event loop lag is a better health signal than a Redis ping
A ping tells you Redis is up. Lag tells you whether this process can afford to wait for it, which is the actual question.
Nobody wanted the library to choose the threshold
50ms is right for one service and absurd for another. Shipping a default would have been shipping an opinion I could not back up.
The interesting decision was not which algorithm to use. It was refusing to make the storage choice permanent.
Three things I would fix before calling this finished, and they are in the code today.
Still open
The lag probe measures in whole milliseconds. It uses Date.now() around setImmediate, so anything under 1ms reads as zero. perf_hooks.monitorEventLoopDelay would give real resolution and a rolling percentile instead of one instant reading.
Still open
The sliding window is the weighted two-bucket approximation, not a true sliding log. It is cheap and close enough for limits, but it is an estimate, and the README should say so plainly.
Still open
Only the token bucket is atomic. The counter path uses MULTI for incr and ttl, and when the TTL comes back unset it makes a second call to add it. Two servers hitting a brand new key can both take that branch. It is a narrow window and the effect is small, but it is not correct, and the Lua treatment should be extended to cover it.
What Limitra came down to:
01
The storage choice became a function you supply per request instead of a line of setup code you can never revisit.
02
Event loop lag answers the question that matters, which is whether this server can afford to wait, rather than whether Redis is technically alive.
03
Slower than the fastest library in a lab. The only one of the three still serving traffic when the database went away.