What a shared Redis limiter solves in a multi-instance monolith
Per-route rate limiting caps how many requests a client can make to a specific endpoint in a time window. Examples are 5 login attempts per minute per IP, or 60 report exports per hour per API key. It protects against abuse such as credential stuffing, scraping, and runaway retry loops. It also enforces fairness, so one tenant hammering an expensive search endpoint cannot starve everyone else. Limits are per route because routes differ in cost and risk: a health check and a password reset should not share a budget.
The common first attempt is an in-memory counter inside each process. That works on one instance and quietly breaks on several. Each instance counts only the requests it sees, so the effective limit scales with the instance count.
Illustrative estimate: with a 100-requests-per-minute limit, 4 instances, and round-robin balancing, a client can get roughly 400 requests per minute before every instance rejects it. Autoscaling then changes your real limit without anyone editing the policy.
A shared Redis counter fixes this. Every instance increments and reads the same key, so the limit is enforced once, across the whole fleet.
+---------------+
clients ---> | load balancer |
+-------+-------+
+-------------+-------------+
v v v
+-----------+ +-----------+ +-----------+
| app #1 | | app #2 | | app #3 |
| limiter | | limiter | | limiter |
+-----+-----+ +-----+-----+ +-----+-----+
+-------------+-------------+
v
+---------------+
| shared Redis |
+---------------+
This fits teams that run a monolith on two or more instances, need different limits per route, and already operate Redis or can tolerate one network round trip per limited request.
clients (keyed by IP or API key)
|
v
+-----------------+
| load balancer |
+--------+--------+
|
+----------------------+----------------------+
| | |
v v v
+---------------+ +---------------+ +---------------+
| app #1 | | app #2 | | app #3 |
| limiter mw | | limiter mw | | limiter mw |
| allow or 429 | | allow or 429 | | allow or 429 |
| route handler | | route handler | | route handler |
+-------+-------+ +-------+-------+ +-------+-------+
| | |
+----------------------+----------------------+
|
| every instance updates the same key
v
+-----------------------------------+
| shared Redis |
| key = rl:{route}:{client} |
| rl:login:203.0.113.7 -> 3 |
| rl:export:key_abc -> 41 |
+-----------------------------------+
Every app instance's limiter middleware checks and increments one shared per-route, per-client counter in Redis, so the limit applies once across the fleet instead of once per instance.
Designing a per-route limit policy and key scheme
Start with a policy table keyed by route pattern or handler name, not raw URL. Matching on the router's pattern (POST /orders/:id/refund) keeps /orders/1 and /orders/2 in one bucket. Any route without an entry falls back to a default, so new endpoints are never unlimited.
| Route | Identity key | Limit | Why this identity |
|---|---|---|---|
POST /login |
IP + submitted username | 10 / 5 min | Caller is unauthenticated; username catches credential stuffing across IPs |
GET /api/search |
API key | 300 / min | Billing and quotas attach to the key |
POST /orders |
User ID | 30 / min | Authenticated; one user across many devices |
| default | User ID, else IP | 120 / min | Safety net |
Use IP only when nothing better exists. NAT, corporate proxies, and mobile carrier gateways put many users behind one address, so a tight IP limit locks out an office. Distributed attackers rotate addresses cheaply, so the same limit barely slows them. Read X-Forwarded-For only when you know exactly which proxies sit in front of the app. Count that many hops from the right and take the entry there. The leftmost value is whatever the client sent.
The key-naming function combines prefix, route, identity, and window start, for example rl:{POST:/login|ip:203.0.113.7}:1729771200. On Redis Cluster, only the text inside the braces is hashed. The sliding-window counter reads both the current and previous window keys in one script, so both keys must land in the same slot. Putting route and identity inside the hash tag does this, and leaving the window outside keeps different buckets spread across slots.
import hashlib, time
TRUSTED_PROXY_HOPS = 1 # exactly one load balancer we control sits in front of the app
# Keyed by router pattern, not raw URL, so /orders/1 and /orders/2 share a bucket.
POLICIES = {
"POST /login": {"identity": ("ip", "username"), "limit": 10, "window": 300},
"GET /api/search": {"identity": ("api_key",), "limit": 300, "window": 60},
"POST /orders": {"identity": ("user_id",), "limit": 30, "window": 60},
}
DEFAULT = {"identity": None, "limit": 120, "window": 60} # None = user_id, else ip
HASHED = {"username", "api_key"} # keep secrets/PII and stray braces out of key text
def client_ip(remote_addr, xff):
# Each trusted proxy appends the address it saw; leftmost entries are client-supplied.
hops = [h.strip() for h in (xff or "").split(",") if h.strip()]
if TRUSTED_PROXY_HOPS == 0 or len(hops) < TRUSTED_PROXY_HOPS:
return remote_addr
return hops[-TRUSTED_PROXY_HOPS]
def _val(field, v):
v = v.strip().lower() if field == "username" else str(v)
return hashlib.sha256(v.encode()).hexdigest()[:16] if field in HASHED else v
def rate_limit_keys(route_pattern, ctx, now=None):
policy = POLICIES.get(route_pattern, DEFAULT) # unlisted routes are never unlimited
fields = policy["identity"] or (("user_id",) if ctx.get("user_id") else ("ip",))
ident = "|".join(f"{f}:{_val(f, ctx[f])}" for f in fields)
route = route_pattern.replace(" ", ":")
w = policy["window"]
start = int(now if now is not None else time.time()) // w * w
# Only the {...} part is hashed in Redis Cluster: route+identity pin current and
# previous windows to one slot; the window start stays outside the braces.
tag = f"rl:{{{route}|{ident}}}"
return f"{tag}:{start}", f"{tag}:{start - w}", policy["limit"], w
# rate_limit_keys("GET /api/orders/:id", {"ip": "203.0.113.7"}, now=1729771200)
# -> ('rl:{GET:/api/orders/:id|ip:203.0.113.7}:1729771200', '...:1729771140', 120, 60)
A per-route policy table with a default fallback, trusted-hop X-Forwarded-For parsing, and a key-naming function that wraps route and identity in a Redis Cluster hash tag so the current and previous window keys share a slot.
How the three algorithms work: fixed window, sliding-window counter, sliding log
Fixed window
Run INCR on a key named for the current window, and set EXPIRE only when the returned count is 1. If both calls run inside one Lua script, a crash between them cannot leave a key with no TTL that never resets. Fixed window is cheap: one key and one round trip. Its weakness is the boundary. A client can spend its full quota in the last second of one window and again in the first second of the next, which puts close to 2x the limit through in a short span.
Sliding-window counter
Keep the current and previous window counters. Estimate the rate as current + previous × (1 − elapsed / window), where elapsed is how far you are into the current window. The estimate assumes the previous window's requests were spread evenly. Real traffic can be front- or back-loaded, so the estimate can be off, but never by more than the previous window's count. For general API routes, that is close enough at two keys of memory per identity.
Sliding log
For exact limits on login or password reset, store each request in a sorted set scored by timestamp. One Lua script runs ZREMRANGEBYSCORE to drop entries older than the window, ZCARD to count what remains, and ZADD only if the count is under the limit. Give each member a unique value, because two requests with the same timestamp would otherwise collapse into one entry. Memory grows with the limit, so keep this algorithm for low-limit routes.
Whose clock
Read time with TIME inside the script, not from the app instance. Instance clocks drift. If one instance computes window boundaries from its own clock and another uses Redis time, they write to different window keys and prune different log ranges, and the effective limit then depends on which instance served the request.
import uuid
import redis
# Sliding log: prune, count, and conditionally add in one atomic script.
# Time comes from Redis TIME, so every app instance shares one clock.
SLIDING_LOG = """
local key = KEYS[1]
local window_ms = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local member = ARGV[3] -- unique per request so equal timestamps don't collapse
local t = redis.call('TIME') -- {seconds, microseconds} from the Redis server
local now_ms = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
-- Drop entries that have aged out of the window (now - window, now]
redis.call('ZREMRANGEBYSCORE', key, '-inf', now_ms - window_ms)
if redis.call('ZCARD', key) < limit then
redis.call('ZADD', key, now_ms, member)
redis.call('PEXPIRE', key, window_ms) -- idle keys clean themselves up
return 1
end
return 0 -- rejected requests are not logged, so they don't extend the lockout
"""
r = redis.Redis(host="localhost", port=6379)
sliding_log = r.register_script(SLIDING_LOG)
def allow_login_attempt(account_id: str) -> bool:
# 5 attempts per 15 minutes per account on the login route
key = f"rl:login:{account_id}"
allowed = sliding_log(keys=[key], args=[15 * 60 * 1000, 5, uuid.uuid4().hex])
return allowed == 1
A sliding-log limiter for a login route that runs ZREMRANGEBYSCORE, ZCARD and ZADD atomically in one Lua script, reads the time from Redis TIME rather than the app instance, and gives each request a unique member.
Choosing an algorithm per route: when to use each and when to avoid it
With one clock source settled, the choice per route comes down to four properties: accuracy, memory per key, Redis operations per request, and how each algorithm behaves at a window boundary.
| Algorithm | Accuracy | Memory per key | Redis work per request | Burst behaviour |
|---|---|---|---|---|
| Fixed window | Approximate | One integer counter | One atomic increment plus expiry | Up to twice the limit across a boundary |
| Sliding-window counter | Close approximation; assumes even traffic in the previous window | Two integer counters | Read two counters and increment one, in one script | Boundary burst largely smoothed |
| Sliding log | Exact | One sorted-set entry per request in the window | Prune, count, add, and expire, in one script | No boundary burst |
Fixed window suits coarse quotas such as exports per hour or invoice generation per day. At that scale a boundary burst is cheap, and one counter per key is as small as state gets. Avoid it for credential-stuffing protection. An attacker who sends a full allowance just before the reset and another just after gets double the guesses, and that edge timing is easy to find.
Sliding-window counter is the default for general API traffic. It uses constant memory per key and needs one round trip. Its error only matters when traffic within a window is very uneven, and on ordinary routes that rarely changes the outcome.
Sliding log belongs on login, password reset, and OTP verification. These routes have low volume, an exact count matters there, and abuse protection justifies the extra cost. Avoid it on high-volume routes. Memory grows with requests per key per window, so a 1,000-per-minute limit means up to 1,000 sorted-set members for every active key. The prune step also gets more expensive as the set grows.
Wiring the limiter into middleware and returning useful 429s
The limiter belongs in middleware that runs after the router has matched a route but before the handler executes. Placement matters because the limiter needs the route template, such as /orders/:id, and not the raw path. Keying on raw paths creates a separate counter for every ID, and no single key ever reaches its limit. Declare each limit as metadata on the route definition itself rather than in a central config file. When someone adds or renames a route, its limit moves with it, and a missing declaration can fail a startup check instead of silently going unlimited.
Returning a useful 429
When a request is rejected, return HTTP 429 with a Retry-After header in seconds, calculated from when the window or oldest log entry expires. Well-behaved clients and SDKs use that value to back off, which spares you a burst of immediate retries. Remaining-quota headers are optional. The conventional X-RateLimit-Remaining header, or the newer RateLimit fields still in IETF draft, let clients slow down before they hit the limit.
Observability
Log every rejection with the route, the key type (user, IP or token), and the limit that applied. Emit a counter metric tagged by route. Without this data you are tuning limits by guesswork. With it, you can see whether a limit is blocking abusers or legitimate peak traffic.
Redis failures
Give Redis calls a short timeout, a few tens of milliseconds, so a slow Redis can't stall every request. Then choose a failure policy per route. Login and password reset should fail closed, because an outage is exactly the window an attacker would use. Read endpoints should fail open, since briefly serving unthrottled reads is better than an outage.
import express, { Request, Response, NextFunction } from "express";
import Redis from "ioredis";
const redis = new Redis("redis://localhost:6379");
type Limit = { max: number; windowSec: number; failOpen: boolean };
// Cap Redis latency so a slow Redis cannot stall every request.
const withTimeout = <T>(p: Promise<T>, ms: number) =>
Promise.race([p, new Promise<never>((_, rej) => setTimeout(() => rej(new Error("redis timeout")), ms))]);
function rateLimit(limit: Limit) {
return async (req: Request, res: Response, next: NextFunction) => {
const now = Math.floor(Date.now() / 1000);
const subject = req.header("x-user-id") ?? req.ip; // key type: user, else IP
// req.route.path is the template ("/orders/:id"), so all IDs share one counter.
const key = `rl:${req.method}:${req.route.path}:${subject}:${Math.floor(now / limit.windowSec)}`;
try {
const results = await withTimeout(redis.multi().incr(key).expire(key, limit.windowSec).exec(), 50);
const count = Number(results?.[0]?.[1]);
res.set("X-RateLimit-Remaining", String(Math.max(0, limit.max - count)));
if (count <= limit.max) return next();
const retryAfter = limit.windowSec - (now % limit.windowSec); // seconds until window resets
// Structured log line; feed it to a counter metric tagged by route.
console.warn(JSON.stringify({ event: "rate_limited", route: req.route.path, subject, max: limit.max }));
res.set("Retry-After", String(retryAfter));
return res.status(429).json({ error: "rate_limited", retryAfterSec: retryAfter });
} catch (err) {
console.error(JSON.stringify({ event: "rate_limit_error", route: req.route.path, failOpen: limit.failOpen }));
if (limit.failOpen) return next(); // reads: unthrottled beats unavailable
res.set("Retry-After", "5");
return res.status(503).json({ error: "rate_limiter_unavailable" }); // login: refuse during outage
}
};
}
const app = express();
// Limits are declared on the route itself, so they move when the route changes.
app.post("/login", rateLimit({ max: 5, windowSec: 60, failOpen: false }), (req, res) => { res.sendStatus(204); });
app.get("/orders/:id", rateLimit({ max: 100, windowSec: 60, failOpen: true }), (req, res) => { res.json({ id: req.params.id }); });
app.listen(3000);
Express route-level middleware that keys a fixed-window Redis counter on the matched route template, returns 429 with Retry-After and remaining-quota headers, logs rejections, and applies a per-route fail-open or fail-closed policy behind a 50 ms Redis timeout.
Testing the limiter: controllable clocks, real Redis, and concurrency
Most limiter bugs live at the edges: the request that lands exactly on a window boundary, or two requests that read the same counter before either writes. A test suite should target those edges directly.
Unit tests with a controllable clock
Pass the current time into the limiter as a dependency, and send it to the Lua script as an argument instead of calling Redis TIME inside the script. Tests can then set the clock to one millisecond before a window rolls over, spend the full quota, advance one millisecond, and assert exactly what the algorithm should allow. For the sliding-window counter, also step to the midpoint of the next window and check that the weighted previous count still blocks requests. Using wall-clock time with sleep makes these tests slow and intermittently failing.
Integration tests against real Redis
A mock cannot run your Lua, apply key expiry, or reproduce how the scripts behave under real execution. Run a disposable Redis container per test run. Assert that keys get a TTL and that per-route keys stay separate. Also kill the connection mid-test and confirm that login fails closed while reads fail open.
Concurrency
Fire many parallel requests at a single key with a limit of N. Assert that exactly N succeed and that the stored counter equals N. If the check and the increment are not one atomic script, this test fails and shows the race.
These tests run in seconds. They catch boundary errors and races that are hard to reproduce once the code is in production, where you would otherwise have to diagnose them during an abuse incident.
Trade-offs, alternatives, and impact on cost and engineering time
Those tests make the limiter trustworthy, but they don't make it free. Every limited request adds at least one Redis round trip, and Redis becomes a hot dependency: when it slows down, every limited route slows with it. Keep each check to a single pipelined call or one short script. Each extra round trip adds latency on every request.
Illustrative estimate (assumed numbers, not a benchmark). Added Redis load = limited requests/s × round trips per request. Added latency ≈ round trips × Redis RTT. At an assumed 3,000 requests/s, one round trip each, and a 0.5 ms RTT, Redis serves 3,000 limiter calls/s and each request gains about 0.5 ms. A second round trip doubles both figures.
Counts are also less durable than they look. With replicated Redis and asynchronous failover, a promoted replica can miss recent increments, so clients briefly get extra allowance. That is fine for abuse protection but not for billing quotas. An in-app limiter also cannot stop volumetric attacks, because traffic that saturates the network or load balancer never reaches application code.
| Option | Route/user aware | Stops floods before app | Shared across instances |
|---|---|---|---|
| Edge or gateway limits | Limited | Yes | Yes |
| Reverse-proxy limits | By path/IP | Partly | Usually per proxy |
| Token bucket in Redis | Yes | No | Yes |
| In-memory limiter | Yes | No | No (single instance only) |
The code itself is small. The ongoing cost is tuning limits and operating Redis. In return, route-aware policy lives next to the routes it protects. It is reviewed and tested together with those routes, rather than duplicated in infrastructure configuration that drifts from the code. Most teams combine both layers: coarse edge limits to absorb floods, and Redis per-route limits to handle application-level abuse.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith rate limiting redis, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.