Redis rate-limit store
The default rateLimit() middleware uses an in-process memory store. That is perfect for a single Node process but unsafe behind multiple replicas. Each instance keeps its own counter, so a client in practice gets N * max requests per window.
DaloyJS ships an optional Redis-backed store at the @daloyjs/core/rate-limit-redis sub-export. Counters live in Redis and are updated atomically with a small Lua script (INCR + PEXPIRE), so every replica observes the same window without a hot key shootout.
Install your Redis client
DaloyJS does not bundle a Redis client. Pick whichever is already in your stack; there are first-class adapters for the two most common options.
# pick one
pnpm add ioredis
pnpm add redis # node-redis v4+Quick start (ioredis)
import IORedis from "ioredis";
import { App, rateLimit } from "@daloyjs/core";
import {
redisRateLimitStore,
ioredisAdapter,
} from "@daloyjs/core/rate-limit-redis";
const redis = new IORedis(process.env.REDIS_URL!);
const app = new App();
app.use(
rateLimit({
windowMs: 60_000,
max: 120,
store: redisRateLimitStore({ client: ioredisAdapter(redis) }),
trustProxyHeaders: true,
}),
);Quick start (node-redis v4+)
import { createClient } from "redis";
import { App, rateLimit } from "@daloyjs/core";
import {
redisRateLimitStore,
nodeRedisAdapter,
} from "@daloyjs/core/rate-limit-redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const app = new App();
app.use(
rateLimit({
windowMs: 60_000,
max: 120,
store: redisRateLimitStore({ client: nodeRedisAdapter(redis) }),
}),
);Failure mode
By default the store is fail-open: if Redis throws (network blip, restart), the request is treated as if it were the only one in the window. That keeps your API available during a Redis outage at the cost of temporarily losing the limit.
Pass onError to change the behavior: return "fail-closed" to surface the error and reject the request, or hook the error into your structured logger:
redisRateLimitStore({
client: ioredisAdapter(redis),
onError: (err) => {
logger.error({ err }, "redis rate-limit store failed");
return process.env.NODE_ENV === "production" ? "fail-closed" : "fail-open";
},
});Custom Redis clients
The store talks to Redis through a tiny contract: a single eval() method. Anything that can run a Lua script can be wrapped in a few lines:
import type { RedisCommands } from "@daloyjs/core/rate-limit-redis";
const myAdapter: RedisCommands = {
eval: (script, keys, args) => myClient.runLua(script, keys, args),
};Key namespacing
Every key is prefixed with daloy:rl: by default. Override prefix per app or environment to avoid collisions on a shared Redis:
redisRateLimitStore({
client: ioredisAdapter(redis),
prefix: "myapp:prod:rl:",
});What it does not do
- It does not pool connections for you. Reuse a single client across requests; do not create one per call.
- It does not synchronize clocks. The reset timestamp returned to clients is computed from the local time plus the Redis-reported TTL, which is good enough for
Retry-Afterbut not for fine-grained billing. - It does not implement sliding windows. The semantics match the in-process store: a fixed window of
windowMswith token-bucket-style counting.