System design
Part 3 of 6 · Rate limitingRedis + Lua Atomic Rate Limiters
Distributed limiters need atomic read-modify-write. Redis + Lua (EVAL/EVALSHA) runs check+debit in one script so concurrent replicas cannot both undercount. Prefer hash tags for Cluster slot affinity; keep scripts short.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Lesson map
Redis + Lua Atomic Rate Limiters
The gateway calls EVALSHA, Redis refills the token bucket inside one script, and the reply is admit or 429.
Architecture. Gateway Got reply. Redis Replied. Gateway B Idle. Token bucket Debited
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB gateway["Gateway Got reply"] redis["Redis Replied"] gateway_b["Gateway B Idle"] bucket["Token bucket Debited"] gateway -->|EVALSHA| redis redis -->|NOSCRIPT| gateway gateway -->|EVAL| redis redis -->|HMGET| bucket redis -->|Debit| bucket redis -->|Reply 1| gateway redis -->|Admit| gateway gateway -->|GET 99| redis gateway_b -->|GET 99| redis gateway -->|SET 100| redis gateway_b -->|SET 100| redis gateway -->|Clock skew| bucket redis -->|KEYS slot| bucket gateway -->|WATCH| redis redis -->|Long script| bucket
Overview
A correct in-process token bucket is still wrong across 40 pods: each pod has its own memory. The distributed answer in interviews is Redis + Lua (or Redis Cell / GCRA).
GET then SET from application code is a TOCTOU race and over-admits under concurrency. Production also cares about script latency, key cardinality, and fail-open vs fail-closed.
By the end of this lesson you should be able to:
- Draw the GET/SET race and the EVALSHA path that closes it
- Say why a script blocks every client, and why that means the script stays short
- Put both sliding-window keys in
KEYSunder one hash tag - Read the server clock with
TIMEinside the script, and say why replicas still match - Choose fail-open vs fail-closed and name retry double-count
Distributed debit: one script vs GET then SET
Prefer
EVALSHA check and debit in one script
Redis runs the script to completion on its main thread. No other command, on any key, can interleave. Concurrent gateways cannot both undercount. One RTT after the script is cached.
- Branch on values you just read. MULTI/EXEC cannot, unless you WATCH and retry.
- Return allowed, remaining, and retry-after so HTTP headers stay honest.
- PEXPIRE on the write so idle keys vanish. Keep the script to a handful of calls.
Alternative
App-side GET, decide, SET
Each command is atomic on its own. The check and the write are two round trips, so another replica can read the same old value in between. That gap over-admits exactly when traffic is highest.
- Pipelines still interleave with other clients.
- A lock key is slower and easier to get stuck than a short script.
- WATCH plus MULTI/EXEC aborts under contention and the retries pile up on a hot key.
Happy path: EVALSHA token bucket
The seven steps match Diagram 1. The user key carries one shared hash tag. The clock is Redis TIME, read inside the script.
- 1
Gateway sends EVALSHA
The replica sends the script hash and the user key, for example {user:42}:rl. - 2
Redis may reply NOSCRIPT
The script is missing from the cache on the first call, after a restart, or after SCRIPT FLUSH. - 3
Gateway sends EVAL once
Redis runs the full script body and caches it for later EVALSHA calls. - 4
Script refills from HMGET and TIME
HMGET reads tokens and ts. redis.call('TIME') is the clock. Tokens refill for the elapsed time. - 5
Script debits, HMSET, and PEXPIRE 60000
The cost is removed, tokens and ts are written back, and idle buckets expire. - 6
Redis replies allowed and tokens left
No other command ran in between. - 7
Gateway admits or returns 429
Allowed traffic is forwarded. A deny is 429 with Retry-After, not a 503.
Diagrams - step by step
Three small diagrams for Redis + Lua rate limiters. Step numbers in the labels give the animation order.
Diagram 1 - Happy path: EVALSHA token bucket check and debit
Sequence
- 1
Gateway replica → Redis
Step 1 EVALSHA sha with key {user:42}:rl
- 2
Redis → Gateway replica
Step 2 NOSCRIPT error
- 3
Gateway replica → Redis
Step 3 EVAL full script body
- 4
Redis → Redis
Step 4 HMGET tokens and ts, refill by elapsed time
- 5
Redis → Redis
Step 5 debit cost, HMSET, PEXPIRE 60000
- 6
Redis → Gateway replica
Step 6 reply 1 and tokens left
- 7
Gateway replica → Gateway replica
Step 7 admit the request or return 429
The gateway sends only the script hash; Redis runs the whole refill and debit with no other command in between. If the script is not cached yet, Redis replies NOSCRIPT and the client sends the full body once. The reply tells the gateway whether to admit or return 429.
Diagram 2 - Failure path: client-side GET then SET over-admits
Sequence
- 1
Gateway A → Redis
Step 1 GET count returns 99, limit is 100
- 2
Gateway B → Redis
Step 2 GET count returns 99
- 3
Gateway A → Redis
Step 3 SET count 100 and admit
- 4
Gateway B → Redis
Step 4 SET count 100 and admit
- 5
Gateway A
Step 5 two requests admitted but the counter moved by one
- 6
Gateway A
Fix - one EVALSHA does check and debit, nothing can interleave
Each command is atomic on its own, but the check and the write are two round trips, so another replica can read the same old value in between. That TOCTOU gap over-admits exactly when traffic is highest. Putting the check and the debit in one Lua script closes the gap.
Diagram 3 - Decision: Lua vs MULTI/WATCH vs plain commands
Decisions
- 1
Step 1 Need check plus debit on a shared counter
- nextStep 2 Is INCR then compare enough?
- Tempting shortcutClient GET then SET
- ?
Step 2 Is INCR then compare enough?
- YesINCR plus PEXPIRE - fixed window only
- No - refill, weights or multi-keyStep 3 Low contention and want plain commands?
- 3
INCR plus PEXPIRE - fixed window only
- ?
Step 3 Low contention and want plain commands?
- YesWATCH plus MULTI/EXEC - retry when EXEC returns nil
- NoLua via EVALSHA - atomic, no retries
- 5
WATCH plus MULTI/EXEC - retry when EXEC returns nil
- Wrong pick on hot keysEXEC aborts pile up into a retry storm
- 6
Lua via EVALSHA - atomic, no retries
- Wrong pick if the script grows largeLong script blocks every Redis client
- 7
Client GET then SET
- Wrong pickTOCTOU race and over-admit under load
- 8
TOCTOU race and over-admit under load
- 9
EXEC aborts pile up into a retry storm
- 10
Long script blocks every Redis client
A bare INCR is atomic and fine for a fixed window. WATCH with MULTI/EXEC is optimistic: it aborts and retries on conflict, which degrades on hot keys. A short Lua script is the usual answer for token bucket or sliding window, but it runs on the Redis main thread, so keep it small.
Atomicity
A Lua script runs on the single Redis main thread and blocks the whole server (every key, every client) until it returns, so no other command can interleave mid-script. That is the atomicity you want for check-plus-debit, and it is why scripts stay short: O(1) work per call, no loops over big keys, no JSON parsing of request bodies.
MULTI/EXEC queues commands. It cannot say "if current is under the limit, then increment, else skip" based on a value you just read, unless you WATCH the key and retry when EXEC returns nil. Retries under contention become a storm on a hot key. Prefer a short script for a token bucket or a sliding window.
A bare INCR plus PEXPIRE is already atomic and is enough for a fixed window. Reach for Lua when you need a refill, a weight, or more than one key.
EVALSHA sends a hash. On NOSCRIPT (first call, restart, or SCRIPT FLUSH), the client sends EVAL with the full body once. Redis caches it. Do not ship the body on every request.
Hash tags and KEYS
{user:42}:rl:0 and {user:42}:rl:1 hash to the same Cluster slot because the tag is the text inside the braces. Pass every key the script touches in KEYS. A key name built inside the script is invisible to Cluster routing and fails with a non-local key error (Script attempted to access a non local key in a cluster node).
The sliding-window sample declares both slots up front:
KEYS[1] = {user:42}:rl:0
KEYS[2] = {user:42}:rl:1The script picks the current and previous slot by bucket parity (bucket % 2). With server time, the client does not know the bucket number, so it cannot build those names itself either. Both keys share the tag {user:42}.
{user:42}:rl token bucket hash
{user:42}:rl:0 sliding slot 0
{user:42}:rl:1 sliding slot 1No TTL means unbounded memory. Every write sets PEXPIRE. Idle identities must die. The token bucket uses 60000 ms. The sliding window uses two window lengths.
Hot keys. A celebrity tenant on one slot becomes the limiter's p99. The script already blocks the whole node while it runs. Split with a secondary dimension (route, or a small set of shards plus a global cap), or use local and global tiers. Replicas of one hot slot do not spread that key.
Retries double-count. A gateway timeout, a retry, a second EVAL, two debits. Gate with an idempotency key (SET seen:{id} NX inside the same script) or accept that a 429 retry must not debit again if the first call already did. Pair that with HTTP 429. One key for a tenant's search, login, and webhooks is a fairness problem, not a Lua problem.
Clock
Read redis.call('TIME') inside the script so every gateway uses the server clock. Client wall clocks jump window ids and token refill.
This is safe on Redis 5+ because scripts replicate by effects: replicas receive the resulting writes, not a re-run of TIME. Since Redis 7 that is the only replication mode. A replica's bucket hash matches the primary byte for byte after a TIME-based write.
Sliding window
Weighted estimate inside one script: previous window times the fraction of the window still overlapping, plus the current count. Deny when that weight is at the limit. register_script sends EVALSHA and falls back to EVAL on NOSCRIPT.
Run python3 redis_sliding.py with redis installed and a Redis 5+ server (REDIS_URL, default redis://localhost:6379/0). This calls the network, so it stays a code sample rather than an in-page playground.
"""Sliding-window counter in one Lua script: server clock, Cluster-safe keys.
Run: python3 redis_sliding.py (pip install redis; needs a Redis 5+ server on REDIS_URL,
default redis://localhost:6379/0, e.g. docker run -p 6379:6379 redis:7)
"""
from __future__ import annotations
import os
import time
import redis
# KEYS[1], KEYS[2] = the two window slots, e.g. {user:42}:rl:0 and {user:42}:rl:1
# ARGV[1] = window_ms, ARGV[2] = limit
# Every key the script touches is declared in KEYS, and both share the hash tag {user:42},
# so Redis Cluster maps them to one slot. Building key names inside the script would break
# Cluster routing (and the client does not even know the bucket number, because the
# script reads the server clock).
SLIDING_LUA = """
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local t = redis.call('TIME') -- server clock: {seconds, microseconds}
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local bucket = math.floor(now / window)
local curr_key = KEYS[1 + bucket % 2] -- even buckets use slot 0, odd use slot 1
local prev_key = KEYS[1 + (bucket + 1) % 2]
local function count(key, b) -- a slot only counts if it holds bucket b
local v = redis.call('HMGET', key, 'b', 'n')
if tonumber(v[1]) == b then return tonumber(v[2]) end
return 0
end
local curr = count(curr_key, bucket)
local prev = count(prev_key, bucket - 1)
local elapsed = now % window
local weight = prev * ((window - elapsed) / window) + curr
if weight >= limit then
return {0, 0, window - elapsed} -- denied, retry after ms
end
if curr == 0 then
redis.call('HSET', curr_key, 'b', bucket, 'n', 1) -- claim the slot for this bucket
else
redis.call('HINCRBY', curr_key, 'n', 1)
end
redis.call('PEXPIRE', curr_key, window * 2) -- TTL so idle users cost no memory
return {1, math.floor(limit - weight - 1), window - elapsed}
"""
class SlidingLimiter:
def __init__(self, r: redis.Redis, window_ms: int, limit: int) -> None:
self.script = r.register_script(SLIDING_LUA) # EVALSHA, falls back to EVAL on NOSCRIPT
self.window_ms, self.limit = window_ms, limit
def allow(self, user: str) -> tuple[bool, int, int]:
tag = "{user:" + user + "}" # hash tag = same Cluster slot
keys = [f"{tag}:rl:0", f"{tag}:rl:1"]
ok, remaining, retry_ms = self.script(keys=keys, args=[self.window_ms, self.limit])
return bool(ok), int(remaining), int(retry_ms)
if __name__ == "__main__":
r = redis.Redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379/0"))
r.delete("{user:42}:rl:0", "{user:42}:rl:1")
lim = SlidingLimiter(r, window_ms=1000, limit=5)
results = [lim.allow("42")[0] for _ in range(8)]
print("burst of 8, limit 5 per 1s:", results)
time.sleep(1.2) # previous window now only partly counts
print("after 1.2s:", lim.allow("42"))Sample run on Redis 8.0: a burst of 8 with limit 5 per second returned five allows, then three denies. After 1.2 seconds the next call was allowed, because the previous window only partly counts. The same script on a local 3-node Redis Cluster allowed 5, then denied. The older pattern that built key names inside the script failed there with "attempted to access a non local key".
Token bucket
Store tokens and ts in one hash. Refill with server time, debit cost when the balance covers it, HMSET on both paths, and PEXPIRE 60000. The client computes the same SHA-1 Redis uses, calls EVALSHA, and on NOSCRIPT calls EVAL.
Run npm i ioredis && npx tsx redis_token_bucket.ts against Redis 5+. The key is {user:42}:rl, matching Diagram 1.
// Token bucket in one Lua script via ioredis: EVALSHA first, EVAL once on NOSCRIPT.
// Run: npm i ioredis && npx tsx redis_token_bucket.ts (Redis 5+ on REDIS_URL or localhost:6379)
import { createHash } from "node:crypto";
import Redis from "ioredis";
// KEYS[1] = bucket hash, e.g. {user:42}:rl ARGV[1] = refill per second, ARGV[2] = capacity, ARGV[3] = cost
// The clock comes from redis.call('TIME') so all gateways share one clock. This is safe on
// Redis 5+ because scripts replicate by effects (the HMSET result), not by re-running TIME.
export const TOKEN_LUA = `
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local cap = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or cap
local ts = tonumber(data[2]) or now
tokens = math.min(cap, tokens + (math.max(0, now - ts) / 1000.0) * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 60000)
return {allowed, math.floor(tokens)}
`;
const TOKEN_SHA = createHash("sha1").update(TOKEN_LUA).digest("hex"); // same hash Redis uses
export class TokenBucket {
constructor(private redis: Redis, private rate: number, private cap: number) {}
async take(user: string, cost = 1): Promise<{ allowed: boolean; left: number }> {
const key = `{user:${user}}:rl`; // hash tag keeps related keys on one slot
const args = [this.rate, this.cap, cost];
let res: [number, number];
try {
res = (await this.redis.evalsha(TOKEN_SHA, 1, key, ...args)) as [number, number]; // step 1
} catch (e) {
if (!String(e).includes("NOSCRIPT")) throw e; // step 2: script cache miss only
res = (await this.redis.eval(TOKEN_LUA, 1, key, ...args)) as [number, number]; // step 3
}
return { allowed: res[0] === 1, left: res[1] };
}
}
async function main(): Promise<void> {
const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
await redis.script("FLUSH"); // force the NOSCRIPT fallback once
await redis.del("{user:42}:rl");
const tb = new TokenBucket(redis, 2, 5); // 2 tokens per second, burst of 5
const burst = await Promise.all(Array.from({ length: 8 }, () => tb.take("42")));
console.log("burst of 8, capacity 5:", burst.map((r) => (r.allowed ? "ok" : "429")).join(" "));
await new Promise((r) => setTimeout(r, 1100)); // about 2 tokens refill
console.log("after 1.1s:", (await tb.take("42")).allowed, (await tb.take("42")).allowed, (await tb.take("42")).allowed);
await redis.quit();
}
main();Sample run on Redis 8.0: a burst of 8 with capacity 5 returned ok five times, then 429 three times. After 1.1 seconds (about 2 tokens refilled at 2 per second) the next three calls were allow, allow, deny. SCRIPT FLUSH forced the NOSCRIPT path once. MONITOR showed EVALSHA followed by EVAL.
Redis down
| Policy | User sees | Risk |
|---|---|---|
| Fail-closed | 503 or 429 | A Redis blip becomes an API outage |
| Fail-open | Traffic to origin | Stampede. Limits vanish. |
| Fail-open plus a local limiter | A coarse per-pod cap | N pods times the local cap still overshoots the global limit. It buys time. |
Document the choice. Login and payments often fail closed, or fail to a tight local cap. Read-mostly GETs may fail open with an alert. Where the limiter sits is gateway layering.
Alternatives: Redis Cell (CL.THROTTLE, GCRA), an Envoy global rate limit service, DynamoDB atomic counters. Interviews still want the Lua.
Interview Q&A
Why Lua instead of GET then SET from the app?
Answer
Two replicas both read "under the limit" and both admit. Each command is atomic. The pair is not. A Lua script (or a transaction that can branch) makes check and debit one step, and it blocks the whole Redis main thread while it runs, so nothing else interleaves. Pipelines do not close that race. Keep the script short because every client waits on it.
Lua vs MULTI/EXEC?
Answer
Lua can branch on values just read and finishes without a retry. MULTI/EXEC queues commands. Conditional logic needs WATCH, and EXEC returns nil on conflict. Those aborts pile up on a hot key. A bare INCR is enough when you only need a fixed window.
How do Cluster multi-key scripts work?
Answer
Every key the script touches must hash to the same slot, and every one of those keys must be passed in KEYS. Wrap the identity in {user:42} so {user:42}:rl:0 and {user:42}:rl:1 agree. A missing tag is CROSSSLOT. A key name built inside the script never reaches the router and fails with a non-local key error.
What do you do when Redis is down?
Answer
Product call: fail closed (503) vs fail open (local limiter or the origin). Say the blast radius. Login often stays closed. A bulk read-only route may open with a per-pod cap and a page. A successful limiter deny is still a 429, not a 503.
Hot key?
Answer
One tenant hashes to one slot and becomes Redis p99, and the script blocks that node's main thread while it runs. Split dimensions (route, shard), use local plus global tiers, or isolate the tenant. Adding replicas of a single hot hash slot does not move the key.
Redis Cell / GCRA?
Answer
A module that implements the generic cell rate algorithm. It is a real alternative to hand-rolled Lua. You should still be able to write the sliding-window script and the token-bucket script.
Can retries double-count an increment?
Answer
Yes. Gateway timeout, retry, second EVAL, two debits. Dedup with a request id set NX inside the same script, or make the client's 429 retry a new debit only when the first call did not land.
Why Redis TIME inside Lua?
Answer
N gateways have N wall clocks. Window ids and token refill jump or stall. redis.call('TIME') gives one server clock. On Redis 5+ the script replicates by effects, so the replica stores the writes and does not re-run TIME. Since Redis 7 that is the only mode.
Why PEXPIRE?
Answer
No TTL means every identity you ever saw stays forever. Expire at two windows for the sliding counter, or at a multiple of the idle burst for the token bucket (60000 ms in the sample).
EVAL vs EVALSHA?
Answer
EVALSHA sends the SHA-1 of the body. Same atomicity, fewer bytes. Handle NOSCRIPT by sending EVAL once. SCRIPT FLUSH and a restart both miss the cache. The TypeScript sample computes that SHA locally and falls back on NOSCRIPT.
Pitfalls
On paper: two gateways, limit 1, both GET 0, both SET. Then list the seven EVALSHA steps for {user:42}:rl, including NOSCRIPT and TIME. For the sliding window, name both KEYS and the shared hash tag. Say fail-open vs fail-closed for /login and for GET /public.
Go Deeper
- Redis rate limiting howto
- Redis EVAL
- Redis Cluster hash tags
- Stripe: Scaling your API with rate limiters
- Redis Lua rate limiting (YouTube search)
Cluster: algorithms · next gateways · hub