A store’s entire job is to run a pure read-modify-write transform atomically per key and persist the result with a TTL when asked. That single contract — one atomic primitive — is what makes ThrottleKit’s N algorithms × M backends matrix collapse to N + M: algorithms are pure functions of state, backends are pure transport for one atomic read-modify-write, and no backend ever contains rate-limiting math.Documentation Index
Fetch the complete documentation index at: https://mintlify.com/AmeyaBorkar/throttlekit/llms.txt
Use this file to discover all available pages before exploring further.
The Store interface
apply receives a Transform — a pure function (state: S | undefined) => ApplyOutcome<S, R> that may optionally carry a .lua rider for Lua-capable stores. The store runs the transform atomically. If the transform carries a Lua rider and the store is Lua-capable (Redis), it executes via EVALSHA in a single round trip. Every other store ignores the rider and runs the JavaScript function body — correctness never depends on the Lua path existing.
Adding a new backend is implementing one method:
apply. Adding a new algorithm never touches a store. This orthogonality is the central design lever.MemoryStore — the default
MemoryStore is the default store when none is provided to rateLimit. It is exact, single-process, and supports the synchronous fast path.
applySync needs no locks at all. The async apply simply wraps applySync.
O(1)-amortized expiry. Keys are bucketed into slots by expiry tick via a hierarchical timing wheel. Advancing the wheel processes only the slots that have come due, so expiry costs O(due keys), not O(all keys). The sweep timer is unref’d and can be disabled for edge runtimes.
Adversarial-key-flood defense. With maxKeys set, cardinality is bounded by an approximate CLOCK/second-chance ring — O(1) and reorder-free while bounding memory against a flood of unique (e.g. spoofed-IP) keys.
No serialization. State is stored as native objects and numbers — zero JSON overhead on the hottest path.
RedisStore — distributed, atomic
RedisStore runs each check as a single atomic EVALSHA Lua script — one round trip to Redis, no read-modify-write race, no WATCH/MULTI retry storms on hot keys.
- Lua
EVALSHA(built-in strategies): When the transform carries a.luarider,applyruns the script viaEVALSHA. ANOSCRIPTerror (cache flushed by restart/failover) triggers anEVAL+ re-cache fallback. - Optimistic concurrency (custom strategies): Without a Lua form, the store falls back to
WATCH/GET/MULTI/EXECwith bounded retries — correct for any user-authored pure strategy without requiring Lua.
RedisStore derives now from the Redis server clock (redis.call('TIME')) by default, so node clock skew cannot corrupt shared state. Multiple app nodes writing to the same key see one authoritative time.
Redis Cluster support. Keys are hash-tagged so multi-key scripts (multi-dimensional checks) co-locate on one slot; independent keys distribute across shards normally.
Other distributed backends
All distributed stores run the same pure JavaScript transform — there is no per-backend algorithm to keep in sync — and all encode state as JSON text for cross-backend bit-identity.| Backend | Subpath | Atomicity mechanism |
|---|---|---|
| In-memory | (built-in) | Single-threaded synchronous RMW |
| Redis | throttlekit/redis | EVALSHA Lua (built-ins) / WATCH-MULTI OCC (custom) |
| Postgres | throttlekit/postgres | pg_advisory_xact_lock + INSERT … ON CONFLICT upsert |
| DynamoDB | throttlekit/dynamodb | Conditional PutItem CAS on a version attribute |
| Deno KV | throttlekit/deno | atomic().check(versionstamp).commit() |
| Cloudflare Durable Object | throttlekit/cloudflare | blockConcurrencyWhile actor serialization |
| Cloudflare D1 | throttlekit/cloudflare | Version CAS via conditional UPDATE … WHERE version = ? |
Conformance guarantees — proven bit-identical across 6 backends
Every shipped store is verified through the same conformance suite (runStoreConformance), which asserts five properties:
- Persists and mutates — the transform’s new state is committed.
- Isolates independent keys — applying to key
Anever affects keyB. resetclears — afterreset(key), the next apply seesundefinedstate.- Expires after TTL — keys are logically expired after their TTL passes.
- Applies atomically — 200 concurrent applies on one key read back exactly 200. A non-atomic RMW would interleave and lose writes; an atomic store always lands exactly N.
(arrivals, costs, clock) timelines through both the JavaScript path and the Redis-Lua path and asserts that every decision in the stream is byte-identical. This is the proof behind the claim that developing in-memory and deploying on Redis produces no behavioral difference.
Failure modes: fail-open vs fail-closed
When a distributed store is unreachable,apply rejects with a StoreUnavailableError. What happens next is determined by your explicit fail policy — there is no implicit default behavior.
fail: "open"— admit the request when the store is unreachable. Use for availability-critical paths where occasional over-admission during an outage is preferable to rejecting legitimate traffic.fail: "closed"— reject the request when the store is unreachable. Use for security-critical limits where over-admission during an outage is unacceptable.
fail mode is configured at the adapter layer (e.g. expressRateLimit, withRateLimit), not on the store itself. A twoTier(leased) limiter keeps serving local credits through a brief L2 outage — the local credit pool acts as a natural buffer.
Security-critical limits (authentication endpoints, payment flows) should use
fail: "closed". Availability-critical limits (general API access) should use fail: "open". The library never guesses — both directions require an explicit choice.Implementing a custom store
Implement theStore interface against any backend with atomic semantics — Memcached (CAS), custom in-memory structures, or any database with conditional writes: