Published: October 26, 2026 · by Srinu Desetti (creator & maintainer)
rate-limit-valkey: Distributed Rate Limiting for Express on Valkey
rate-limit-valkey is a Valkey (opens in a new tab) store for the express-rate-limit middleware — so rate-limit counters are shared across all your server instances instead of living in each process's memory. It works with any Valkey client that can send raw commands (such as iovalkey): the store never imports a client itself — you pass in a sendCommand function, so it stays client-agnostic. I created and maintain this package as the application-layer entry point of the Valkey Node.js stack.
| Package | rate-limit-valkey (opens in a new tab) v0.1.0 |
| License | MIT |
| Runtime dependencies | 0 (peer: express-rate-limit ≥ 8.6.0) |
| Module formats | ESM + CJS, TypeScript declarations included |
| Node.js | ≥ 16 |
| Client coupling | None — bring any client via sendCommand |
| Source | github.com/webdevelopersrinu/rate-limit-valkey (opens in a new tab) |
The Problem: One Limit per Process Is No Limit at All
express-rate-limit ships with a memory store. It is perfect for a single process — and quietly wrong the moment you scale past one.
The memory store keeps each client's hit count in the process's own heap. Behind a load balancer, every instance keeps its own private count, and three real problems appear:
- Your real limit multiplies by instance count. Configure
max: 100and run 5 instances behind a round-robin load balancer — a client can land ~100 requests on each instance before any of them says no. Your "100 requests per window" is actually ~500. Scale to 20 pods and it's ~2,000. The limit you wrote in code is not the limit you enforce. - Counters reset on every restart. Deploy, crash, autoscale — the heap is gone, and every abusive client gets a fresh window. The clients most worth limiting are exactly the ones probing for that reset.
- Sticky routing doesn't save you. Even with session affinity, a client that rotates connections (or an attacker that deliberately does) spreads hits across instances and under-counts everywhere.
The fix is structural, not a tuning knob: move the counter out of the process and into a store every instance shares. That is the entire purpose of rate-limit-valkey — one Valkey key per client, one TTL per window, and every Express instance incrementing the same number.
Usage: Wiring the Store into express-rate-limit
Install the store alongside a Valkey client and the middleware:
npm install rate-limit-valkey iovalkey express-rate-limitThen hand the store a function that can send a raw command. With iovalkey, that is client.call:
Notice what the store doesn't take: a host, a port, a connection config. It only ever sees sendCommand. Connection management, retries, TLS, pooling — all of that stays where it belongs, in the client you already run.
Valkey Cluster
For a Valkey Cluster, pass sendCommandCluster instead — it receives routing details ({ key, isReadOnly, command }) so cluster-aware clients can route the command to the right node:
Provide either sendCommand or sendCommandCluster, not both.
Options
| Option | Description | Default |
|---|---|---|
sendCommand | Function that sends a raw command to Valkey and returns the reply | — |
sendCommandCluster | Cluster alternative that receives { key, isReadOnly, command } | — |
prefix | Text prepended to every key stored in Valkey | rl: |
The prefix matters when one Valkey serves several limiters (per-route limits, per-API-key limits) or several applications — each gets its own key namespace, and SCAN rl:* style inspection stays sane.
How It Works: Atomic by Construction
The store keeps each client's hit count in a single Valkey key with a TTL equal to the rate-limit window. The interesting part is how it increments.
A naive store would do increment-then-expire as two round trips:
rate-limit-valkey instead runs increment-and-expiry as Lua scripts — loaded once with SCRIPT LOAD, executed with EVALSHA — so the count-and-expiry logic happens atomically on the Valkey server. There is no window between "counted" and "expiring", no matter how many Express instances hammer the same key at once. Get runs the same way.
That last line is the operational detail that matters in production: Valkey's script cache is not durable. A failover, a restart, or a SCRIPT FLUSH empties it, and a store that doesn't handle NOSCRIPT starts throwing on every request at the worst possible moment. Here the error is caught, the script is reloaded, and the operation is replayed — your limiter survives a Valkey failover without a single 500.
Design Notes — Decisions I Kept and Why
Client-agnostic by contract, not by adapter
The store's only dependency on a Valkey client is a function signature. There is no iovalkey import, no client registry, no adapter layer per client — if your client can send a raw command and return the reply, it works. This is why iovalkey sits in devDependencies (tests need a real client) and nowhere in the runtime graph: installing rate-limit-valkey pulls in zero runtime dependencies.
Peer dependency on express-rate-limit, not a wrapper around it
rate-limit-valkey implements express-rate-limit's store contract (peer dependency ≥ 8.6.0) rather than wrapping the middleware. You keep the middleware's full option surface — keyGenerator, skip, standard headers, per-route limiters — and swap only the storage layer. One line changes; everything else you configured stays.
Dual ESM/CJS build
The package ships .mjs and .cjs entry points with matching TypeScript declarations for each, selected via conditional exports. Whichever module system your Express app uses, import { ValkeyStore } and require("rate-limit-valkey") both resolve cleanly, types included.
Provenance
rate-limit-valkey is a Valkey-native port of rate-limit-redis — the same store contract and the same atomic-script approach, targeting Valkey and its client ecosystem (iovalkey and friends). Behavior parity keeps migration mechanical: if you've used rate-limit-redis, the constructor shape will look familiar on purpose.
This is a solo project — I designed, built, and maintain rate-limit-valkey independently as the application-layer piece of the Valkey Node.js stack, alongside valkey-errors and the rest of the client foundation.
What's Next?
rate-limit-valkey is where the Valkey client stack meets application code: the errors package defines failure identity, the parser decodes the wire, the client manages connections — and this store puts all of it to work protecting an actual Express API.
← Back to Open Source overview
Frequently Asked Questions
Why not just use express-rate-limit's built-in memory store?
It's correct for exactly one process. Behind a load balancer, every instance keeps its own count, so your effective limit is max × instance count, and every restart or deploy resets all counters to zero. A shared Valkey store makes the limit global across instances and restart-proof — the counter lives in Valkey with a TTL, not in any process's heap.
Which Valkey clients does it work with?
Any client that can send a raw command and return the reply. The store never imports a client — you pass a sendCommand function (for iovalkey, that's (...args) => client.call(...args)), or sendCommandCluster for Valkey Cluster, which receives { key, isReadOnly, command } for routing. This keeps the package at zero runtime dependencies and leaves connection management entirely to the client you already run.
Why Lua scripts instead of plain INCR and PEXPIRE?
Because increment and expiry must be atomic. As two separate commands, a crash or race between them can leave a counter with no TTL — a client rate-limited forever — and concurrent instances can interleave. The store loads its scripts once with SCRIPT LOAD and runs them with EVALSHA, so count-and-expiry executes as one atomic operation on the Valkey server, in one round trip per hit.
What happens when Valkey restarts or fails over and the script cache is lost?
The server replies NOSCRIPT instead of executing. The store catches that, reloads the script with SCRIPT LOAD, and safely replays the operation — so a failover or SCRIPT FLUSH doesn't turn your rate limiter into a source of 500s. The counters themselves follow your Valkey persistence/replication setup, since each is just a key with a TTL.