Search docs

Jump between documentation pages.

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.

bash
# pick one
pnpm add ioredis
pnpm add redis        # node-redis v4+

Quick start (ioredis)

ts
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+)

ts
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:

ts
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:

ts
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:

ts
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-After but not for fine-grained billing.
  • It does not implement sliding windows. The semantics match the in-process store: a fixed window of windowMs with token-bucket-style counting.