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.
- 01requestClientReplica ARequest lands on replica Akey = daloy:rl:<key>
- 02asyncReplica ARedisAtomic INCR + PEXPIRE (Lua)count = 120 / max 120
- 03responseReplica AClientAllowed, at the limit200 OK
- 04requestClientReplica BNext request load-balanced awaysame key, different process
- 05asyncReplica BRedisINCR sees the shared countcount = 121 > 120
- 06responseReplica BClientRejected by the shared window429 + Retry-After
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 N× 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.
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:
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)
Quick start (node-redis v4+)
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
keyGeneratorthat returns a stable id. - Per source IP: set
trustProxyHeaders: trueonly when you run behind a trusted proxy or load balancer that overwritesX-Forwarded-For. The key is then the firstX-Forwarded-Forentry (orX-Real-IP), and falls back toglobalwhen neither is present.
Security: never settrustProxyHeaders: trueon a service reachable directly. A client can send anyX-Forwarded-Forit 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:
Fail-open only works if your Redis client fails fast. A barenew 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'srequestTimeoutMs, give up) before it ever reachesonError. 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:
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:
Key namespacing
Every key is prefixed with daloy:rl: by default. Override prefix per app or environment to avoid collisions on a shared Redis:
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.