All work

Limitra · npm package · rate limiting

A rate limiter that survives its own database going down

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.

Role

Sole author

Language

TypeScript, ESM

Stores

Redis, in-process memory

Stars

57

The choice nobody gets to revisit

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.

What that failure actually looks like

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.

How I got there

Three passes at it, in the order they actually happened.

Explore

Tried the obvious fixes first

Bigger Redis
Longer timeout
Retry the call

Realise

Every one of them still waits on Redis

Bigger Redis
Longer timeout
Retry the call

Insight

Ask the server, not the config file

Watch the loop
Two paths
Count locally

The fix was not a faster store. It was a later decision.

Make the choice a runtime 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.

The whole adaptive limiter

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 };
};
src/adaptive.ts

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.

One request, end to end

The path a single request takes, and where the decision happens.

  1. Middleware takes the key

    Pulls an identifier off the request, IP by default, or whatever your key generator returns. Then it calls consume and waits.

    src/middleware.ts

  2. The selector is asked

    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

  3. The probe measures lag

    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

  4. Normal: sliding window on Redis

    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

  5. Panic: fixed window in memory

    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

  6. Headers go back

    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

Both paths, side by side

The same request, down whichever lane the selector picked. Nothing above the store layer knows which one it got.

Middleware

src/middleware.ts

  1. Take a key off the request
  2. Call limiter.consume(key)
  3. Set the rate-limit headers
  4. Block with 429, or carry on

Adaptive limiter

src/adaptive.ts

  • Runs your selector
  • Looks the returned name up
  • Throws if it is not a strategy

Health probe

src/utils/health.ts

  • Schedules a callback, measures how late it runs
  • Over the threshold means the loop is already behind

healthy · normal

Sliding window

accurate, and it costs a round trip

  • Weighs this window against the last
  • A burst on the boundary cannot slip past

Redis store

  • Counters shared across the cluster
  • Token bucket runs as one Lua script

Correct across every server

one limit, however many machines

struggling · panic

Fixed window

rough, and it costs nothing

  • A plain counter, reset on the boundary
  • Local to the process, so no network at all

Memory store

  • A JavaScript Map
  • Cannot be taken away by a network problem

Still serving

13ms through the whole outage

The part that had to be atomic

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.

What it costs when everything is fine

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.

Then I cut the cable

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.

Same outage, three libraries

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 building it taught me

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.

Where it is still rough

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

Move the decision, not the algorithm

The storage choice became a function you supply per request instead of a line of setup code you can never revisit.

02

Measure the process, not the dependency

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

Pay 2.7ms to delete the outage

Slower than the fastest library in a lab. The only one of the three still serving traffic when the database went away.