conclimit
v0.1.0
Published
Distributed concurrency and rate limiting on Redis. A limit of 5 stays 5 across forty replicas. Works with ioredis and node-redis v4+, survives Redis Cluster and a crashed holder, and keeps no settings in Redis to lose.
Maintainers
Readme
conclimit
A limit of 5 should still be 5 when you scale to forty replicas.
p-limit and p-queue limit concurrency inside one process. Deploy forty pods
and your limit of 5 is a limit of 200 — usually discovered by the third party
you were trying not to overload.
import { Limiter } from 'conclimit';
const limiter = new Limiter(redis, { key: 'stripe-api', maxConcurrent: 5 });
// At most 5 of these are running at once. Across every process.
await limiter.run(() => stripe.charges.create(params));Works with ioredis and node-redis v4+, survives Redis Cluster, survives a crashed holder, and keeps no configuration in Redis to lose.
npm install conclimitWhy this exists
bottleneck does this and is the only widely-used option — 10.5M downloads a
week. Its last commit was July 2020. Its three most-reacted open issues:
| | |
|---|---|
| ERR SETTINGS_KEY_NOT_FOUND in clustering mode | 14 reactions, open since 2022 |
| "Is bottleneck still maintained?" | 13 reactions |
| "Support for node-redis v4" | 12 reactions, zero replies |
A commenter on the first one asks: "Do we have any workaround to resolve this issue? Or do we have any alternate library?"
This is that. Not a fork — the two failure modes above are design consequences, and the fixes are in the design.
The two things that were wrong, and how this differs
No settings live in Redis. SETTINGS_KEY_NOT_FOUND happens because
bottleneck stores a limiter's configuration in a Redis key with a TTL, and when
that key expires under a running limiter the Lua script has nothing to read and
throws. Here every parameter — concurrency, spacing, reservoir, lease — is
passed in on every call. There is no key to lose, so losing it is survivable:
await redis.del(`{conclimit:my-limiter}:running`, `{conclimit:my-limiter}:meta`);
await limiter.tryAcquire(); // works; rebuilt from its argumentsThat is a test, not a claim.
Every key of one limiter shares a hash slot. Redis Cluster rejects a script
whose keys hash to different slots, which is the other half of why clustering
mode is where the bugs are. The limiter's name is wrapped in {} so the tag
forces one slot, and the test asserts the two keys agree on CLUSTER KEYSLOT.
A crashed process must not shrink the limit
A holder that dies never releases, so without an expiry its slot is gone forever and the limiter deadlocks at a capacity that drops every time something crashes. Slots are leases in a sorted set scored by expiry, and every acquire sweeps the expired ones first.
The obvious problem with a lease is the opposite one: a slow job losing its slot
to the expiry meant to catch dead holders, and then two jobs running under a
limit of one. run() heartbeats for as long as the function takes, so the lease
only has to cover a stall rather than the slowest job you will ever have. Both
directions are tested — the reclaim, and the long job that must not be
reclaimed.
Everything happens in one script
sweep expired leases → refill reservoir → check tokens
→ check concurrency → check spacing → take the slotSplit across round trips this would not be a limiter: two callers would both
read "one slot free" and both take it. One EVALSHA per acquire, with a
NOSCRIPT fallback for when Redis has been restarted or flushed.
When a slot is refused, the script says when to try again — the next token's arrival, or the earliest lease expiry — rather than leaving the caller to guess an interval and either hammer Redis or sleep through an opening. The wait is jittered so that replicas woken by the same expiry do not stampede.
What it does
new Limiter(redis, {
key: 'stripe-api', // processes sharing this name share the limit
maxConcurrent: 5, // at most 5 running at once, everywhere
minTime: 200, // at least 200ms between two starts, everywhere
reservoir: 100, // a token bucket…
reservoirRefillAmount: 100,
reservoirRefillInterval: 60_000, // …refilling 100 every minute
leaseMs: 30_000, // how long a slot survives a dead holder
acquireTimeoutMs: 60_000,
});await limiter.run(fn); // acquire, heartbeat, release, always
const wrapped = limiter.wrap(fn); // same, as a function
const slot = await limiter.acquire(); // manual; you release it
const { granted, retryAfterMs, running, tokens } = await limiter.tryAcquire();What it does not do
No priorities, no queue introspection, no groups. bottleneck has all three. This is a limiter, not a job queue — if you need to reorder work, persist it, or watch it, you want a queue, and BullMQ is a better answer than either of us.
It is not a drop-in replacement. The API is deliberately smaller. Migrating means changing call sites, and the honest reason to do it is the two bugs above rather than an easier path.
It does not survive Redis being down. There is no local fallback, because the only two options are to stop (an outage) or to allow the work through (the overload you installed a limiter to prevent). Choosing silently would be worse than either; the error is yours to catch.
Redis Cluster is designed for and hash-tag tested, but the suite runs against a single node. Adding a three-node cluster to CI is on the list, and until it is there the claim is "the keys share a slot", which is what is asserted — not "tested end to end on a cluster".
Tests
npm install
npm run redis:up
npm test # 27 tests against a real Redis
npm run redis:downThe same suite runs against both ioredis and node-redis, because supporting a client in the adapter and testing only the other one is the same as not supporting it. One test takes a slot through ioredis and is refused it through node-redis, which is as close to two processes as a single test gets.
Built with Claude
Claude wrote most of this code. The design is mine, and so is the decision to build it: the choice came from download counts and issue reactions rather than from an empty name on npm — a niche nobody has entered is usually a niche nobody wants, and this one has 10.5 million weekly downloads asking for a maintainer.
Licence
MIT.
