Open Source
rate-limit-valkey

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.

Packagerate-limit-valkey (opens in a new tab) v0.1.0
LicenseMIT
Runtime dependencies0 (peer: express-rate-limit ≥ 8.6.0)
Module formatsESM + CJS, TypeScript declarations included
Node.js≥ 16
Client couplingNone — bring any client via sendCommand
Sourcegithub.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:

  1. Your real limit multiplies by instance count. Configure max: 100 and 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.
  2. 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.
  3. 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.

the-fix-in-one-line.js
// Before: each process counts alone — limit × instances, reset on restart
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })

// After: every instance shares one counter in Valkey — the limit is global
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  store: new ValkeyStore({ sendCommand: (...args) => client.call(...args) }),
})

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-limit

Then hand the store a function that can send a raw command. With iovalkey, that is client.call:

app.js
import rateLimit from "express-rate-limit";
import { ValkeyStore } from "rate-limit-valkey";
import Valkey from "iovalkey";

const client = new Valkey({ host: "localhost", port: 6379 });

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                 // limit each IP to 100 requests per window
  store: new ValkeyStore({
    sendCommand: (...args) => client.call(...args),
  }),
});

app.use(limiter);

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:

cluster.js
new ValkeyStore({
  sendCommandCluster: ({ command }) => cluster.call(...command),
});

Provide either sendCommand or sendCommandCluster, not both.

Options

OptionDescriptionDefault
sendCommandFunction that sends a raw command to Valkey and returns the reply—
sendCommandClusterCluster alternative that receives { key, isReadOnly, command }—
prefixText prepended to every key stored in Valkeyrl:

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:

the-race-a-naive-store-has.js
// Two separate commands — a race condition waiting to happen:
await send("INCR", key)          // instance A and B both increment…
await send("PEXPIRE", key, ttl)  // …and if a crash lands between the two,
                                 // the key lives FOREVER with no expiry.
// A counter that never expires = a client that is rate-limited for life.

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.

script-lifecycle.txt
startup      → SCRIPT LOAD  (script cached server-side, SHA returned)
every hit    → EVALSHA <sha> (one round trip, atomic count + expiry)
cache lost?  → server replies NOSCRIPT
               → store reloads the script and safely replays the call

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.