Search docs

Jump between documentation pages.

Browse docs

Redis rate-limit store

Think of it like… moving the nightclub's clicker from one door to a shared headcount board every door reads from. With many doors (replicas) and a local clicker each, a guest can sneak in N times by trying every door. With a shared clicker (Redis), the cap is honoured everywhere, no matter which door they queue at.

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.

One client hitting two replicas, one shared counter
ClientReplica AReplica BRedis
  1. 01requestClientReplica ARequest lands on replica Akey = daloy:rl:<key>
  2. 02asyncReplica ARedisAtomic INCR + PEXPIRE (Lua)count = 120 / max 120
  3. 03responseReplica AClientAllowed, at the limit200 OK
  4. 04requestClientReplica BNext request load-balanced awaysame key, different process
  5. 05asyncReplica BRedisINCR sees the shared countcount = 121 > 120
  6. 06responseReplica BClientRejected by the shared window429 + Retry-After
Every replica increments the same Redis key, so switching doors does not reset the count. With the in-memory store each replica keeps its own counter and a client can get N times the limit.

When to use Redis (and when not to)

The Redis store is built for long-lived multi-replica deployments: VPS, containers, Kubernetes, Fly.io, Render, ECS, App Runner, Railway. Anywhere you run more than one Node / Bun / Deno process and need a shared counter so a client can't get the limit by load-balancing across replicas.

On edge runtimes (Cloudflare Workers, Vercel, Fastly Compute), prefer the platform's native primitive rather than fronting Redis from every region:

  • Cloudflare Workers: Durable Objects (strongly consistent per-key), or KV / D1 for relaxed consistency.
  • Vercel: Vercel KV (Upstash Redis under the hood) is reachable from edge functions; for very high RPS prefer Edge Config + a Node region for the counter write path.
  • Fastly Compute: Edge Dictionaries for static quotas, KV Store for dynamic counters.

rateLimit() accepts any object implementing the RateLimitStore contract, so each of these platforms can be wired up in a few lines using the same middleware. The Redis adapter shown below is just the most common case.

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+

Run a Redis to point at

The adapter needs a Redis it can reach; DaloyJS does not start one for you. For local development the quickest path is a container:

bash
docker run --rm -p 6379:6379 redis:7-alpine
# then, in your app's environment:
export REDIS_URL=redis://127.0.0.1:6379

In production use a managed Redis (Upstash, ElastiCache, Memorystore, Redis Cloud) and read the URL from the environment. Keep exactly one client per process and share it, see What it does not do below.

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) }),
  }),
);

How clients are keyed

This is the part people get wrong. By default rateLimit() derives a single shared key, global, so every caller lands in one bucket (the Redis key is daloy:rl:global). That is a deliberate safe default: DaloyJS will not key off a spoofable client IP unless you tell it to. To limit per client you have to opt in.

  • Per authenticated user (preferred): pass a keyGenerator that returns a stable id.
  • Per source IP: set trustProxyHeaders: true only when you run behind a trusted proxy or load balancer that overwrites X-Forwarded-For. The key is then the first X-Forwarded-For entry (or X-Real-IP), and falls back to global when neither is present.
ts
app.use(
  rateLimit({
    windowMs: 60_000,
    max: 120,
    store: redisRateLimitStore({ client: ioredisAdapter(redis) }),
    // Per-user bucket; fall back to a single anonymous bucket otherwise.
    keyGenerator: (ctx) => (ctx.state.user as { id?: string })?.id ?? "anonymous",
  }),
);
Security: never set trustProxyHeaders: true on a service reachable directly. A client can send any X-Forwarded-For it likes, mint a fresh bucket per spoofed IP, and walk straight past the limit. Behind a proxy that rewrites the header it is safe; exposed directly it is an evasion hole. When in doubt, key off the authenticated user instead.

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
// logger here is your app's structured logger (e.g. createLogger()).
redisRateLimitStore({
  client: ioredisAdapter(redis),
  onError: (err) => {
    logger.error({ err }, "redis rate-limit store failed");
    return process.env.NODE_ENV === "production" ? "fail-closed" : "fail-open";
  },
});
Fail-open only works if your Redis client fails fast. A bare new IORedis(url) queues commands and retries while disconnected, so during an outage a request blocks for tens of seconds (until the client's retry budget, then the app's requestTimeoutMs, give up) before it ever reaches onError. That is neither fast-open nor fast-closed, just slow, and it will exhaust your connections under load. Construct the client to give up quickly:
ts
// ioredis: fail fast so the store can fall back immediately
const redis = new IORedis(process.env.REDIS_URL!, {
  enableOfflineQueue: false,   // don't queue commands while disconnected
  maxRetriesPerRequest: 1,     // give up after a single retry
  connectTimeout: 500,
});

// node-redis equivalent
const redis = createClient({
  url: process.env.REDIS_URL,
  disableOfflineQueue: true,
  socket: { connectTimeout: 500, reconnectStrategy: (n) => Math.min(n * 50, 500) },
});

With those options a Redis outage resolves in milliseconds: fail-open allows the request immediately, fail-closed rejects it with a 500 immediately. Without them you get the same decision eventually, just after a long stall on every request.

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.