§01 context
I kept running into outbound rate limits while building a WhatsApp integration. An in-memory limiter worked until there was more than one worker. Four workers with an 80-request limit can still send 320 requests a second, so I built valve to give them one budget in Redis or Valkey. Calls wait in each worker until there's room, and when the provider asks them to slow down, they share that wait too.
It can check a shared limit and a tenant or recipient limit together, using GCRA pacing or fixed windows. There's an in-memory store if you only have one process. It installs as valvejs, is still beta, and has ESM and CommonJS entry points, TypeScript declarations, and no runtime or peer dependencies.
The waiting jobs live in memory. If they need to survive a restart, I'd keep them in a durable queue and use valve around the actual API call.
§02 hard parts
The hard parts
- 01
Sharing one limit across workers
Two workers can both see room for one more call and both send it. So checking and taking capacity happen together in Lua on Redis or Valkey, using the store's clock. Every worker needs the same store, prefix, ID, and rules for that to work.
- 02
More than one limit per call
A message might have room in the phone number's budget but still need to wait for that recipient's limit. valve checks both together. While that recipient waits, work for someone else can go ahead. The scope IDs stay the same while a call is queued or retried.
- 03
Respecting Retry-After
When the provider sends Retry-After, IETF RateLimit, or common x-ratelimit headers, valve uses them to pause new calls across workers with the same ID. The fetch wrapper can retry rate-limit responses too. POST and PATCH need an explicit opt-in once you've checked that repeating the call is safe.
- 04
Sharing concurrency across processes
You can limit concurrent work in one process or across workers. The shared version takes renewable Redis or Valkey leases alongside the rate check, so a crashed worker doesn't hold its slots forever. A job still has to stop when its abort signal fires after losing a lease. The lease can't stop external work by itself.
- 05
Not letting the queue grow forever
A call can have a cost, priority, wait deadline, and abort signal. There's a max queue size too, so a busy worker can't keep taking on work it won't get to. Each worker keeps its own queue order, even when the budget and concurrency slots are shared.
stack
- TypeScript
- Redis
- Valkey
- Lua
next →
projector-remote
A phone remote and media backend running on my projector, written in Go.