How to Implement Rate Limiting with Redis: Token Bucket in Lua (2026)
This guide is for backend and platform engineers who need a rate limiter shared across several app instances and would rather own forty lines of Lua than adopt another service. By the end you will have a token bucket and a sliding window limiter running as atomic Redis scripts, middleware for FastAPI and Express that returns 429 with Retry-After and the IETF RateLimit headers, a test that proves the limiter has no race, and a k6 load test. Everything here was run against a throwaway local Redis 7.2.5 server; the scripts use only core commands, so they run unchanged on the current Redis 8.10.3 and Valkey 9.1.3 (both released 26 October 2026). Library versions: redis-py 8.1.0, FastAPI 0.162.2, ioredis 6.0.0, Express 5.3.0, k6 v2.3.0 (my local run used an older k6). You need Docker or a local redis-server, Python 3.10+ or Node.js 20+. If you want the theory first, read the companion explainer, API Rate Limiting Explained (2026).
Step 1: Start a local Redis or Valkey
Use a disposable instance. Never test a limiter against a shared Redis, because FLUSHALL is part of the workflow.
# Redis 8.10.3 (or swap in valkey/valkey:9.1.3; the commands below are identical)
docker run -d --name rl-redis -p 6379:6379 redis:8.10.3 \
redis-server --save "" --appendonly no
redis-cli -p 6379 ping
# PONG
Step 2: Choose a key scheme with Redis Cluster hash tags
Decide the key format now, because changing it later resets everyone's limits. I use rl:{<identity>}:<algorithm>:
rl:{key:sk_live_abc}:tb # token bucket for an API key
rl:{ip:203.0.113.7}:sw # sliding window for an anonymous IP
rl:{tenant:7}:tb # tenant-wide bucket
The braces are a Redis Cluster hash tag: only the text inside {} is hashed with CRC16 mod 16384, so every key for one identity lands in the same slot. That costs nothing on a single server and saves a migration when you move to Cluster. Key on the authenticated identity whenever there is one; per-IP limits are for anonymous traffic only.
Step 3: Write the token bucket Lua script
A token bucket stores two fields per key: tokens left and the time of the last refill. Each call refills for the elapsed time, then spends or refuses. Save this as token_bucket.lua:
-- token_bucket.lua: atomic token bucket for Redis 7+ / Valkey 8+
-- KEYS[1] = bucket key, e.g. rl:{user:42}:tb
-- ARGV[1] = capacity (max burst, in tokens)
-- ARGV[2] = refill rate (tokens per second)
-- ARGV[3] = cost of this request (usually 1)
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
-- Use the Redis server clock so app servers with skewed clocks agree
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000) -- ms
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity -- new key starts full
local ts = tonumber(state[2]) or now
-- Refill for the time since the last call, capped at capacity
local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + elapsed * rate / 1000)
local allowed, retry_ms = 0, 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_ms = math.ceil((cost - tokens) * 1000 / rate)
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- Expire once the bucket would be full again, so idle keys clean up
local reset_ms = math.ceil((capacity - tokens) * 1000 / rate)
redis.call('PEXPIRE', KEYS[1], reset_ms + 1000)
-- {allowed 0/1, whole tokens left, ms until retry, ms until full}
return { allowed, math.floor(tokens), retry_ms, reset_ms }
Three design choices are worth knowing. The script reads the clock with TIME on the server instead of taking a timestamp from the app, so ten app servers with drifting clocks still agree. Writing after TIME is allowed because, as the Redis scripting docs say, Redis 7.0 removed verbatim script replication; replicas receive the resulting HSET, not the script. And every key the script touches arrives through KEYS, which Redis requires for scripts to work in Cluster.
Test it with redis-cli --eval, where the comma separates keys from arguments. Capacity 5, refill 1 token per second:
for i in $(seq 1 7); do
redis-cli --eval token_bucket.lua 'rl:{user:42}:tb' , 5 1 1 | tr '\n' ' '; echo
done
Real output from my run:
1 4 0 1000
1 3 0 1994
1 2 0 2986
1 1 0 3981
1 0 0 4974
0 0 969 4969
0 0 963 4963
Five allowed, then refusals saying "retry in about 969 ms". The bucket refilled a few milliseconds' worth between calls, which is why the retry time falls slightly.
Step 4: Add a sliding window log for exact limits
A token bucket allows bursts. For actions where you want "no more than 3 in any 10 seconds, exactly" (password resets, OTP sends, payouts), use a sorted set of timestamps. Save as sliding_window.lua:
-- sliding_window.lua: exact sliding window log using a sorted set
-- KEYS[1] = rl:{user:42}:sw
-- ARGV[1] = window in ms, ARGV[2] = limit, ARGV[3] = unique request id
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
-- Drop entries that have slid out of the window
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, ARGV[3])
redis.call('PEXPIRE', KEYS[1], window)
return { 1, limit - count - 1, 0 }
end
-- Rejected requests are not recorded; retry when the oldest entry expires
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return { 0, 0, tonumber(oldest[2]) + window - now }
for i in $(seq 1 5); do
redis-cli --eval sliding_window.lua 'rl:{user:42}:sw' , 10000 3 req-$i | tr '\n' ' '; echo
done
# 1 2 0
# 1 1 0
# 1 0 0
# 0 0 9983
# 0 0 9978
The request ID must be unique (a UUID), or two requests in the same millisecond overwrite each other in the set. Memory is one entry per accepted request, so keep this for limits in the tens or hundreds, not thousands.
Step 5: Prove the limiter is atomic
The most common hand-rolled limiter reads a counter, decides in the application, then writes it back. Under concurrency, every request reads the same stale value. This script fires 200 concurrent requests at a limit of 10, first with naive GET and SET, then with the Lua script:
# race_test.py: 200 concurrent requests against a limit of 10 (redis-py 8.1.0)
import asyncio
from pathlib import Path
import redis.asyncio as redis
r = redis.Redis(port=6379, max_connections=200)
lua = r.register_script(Path("token_bucket.lua").read_text())
async def naive(key, capacity=10):
# read-modify-write in the app: the classic race
used = int(await r.get(key) or 0)
if used >= capacity:
return 0
await r.set(key, used + 1, ex=60)
return 1
async def atomic(key):
# refill of 0.001 tokens/s means effectively no refill during the test
allowed, *_ = await lua(keys=[key], args=[10, 0.001, 1])
return allowed
async def main():
await r.delete("race:naive", "race:{lua}")
n = sum(await asyncio.gather(*[naive("race:naive") for _ in range(200)]))
a = sum(await asyncio.gather(*[atomic("race:{lua}") for _ in range(200)]))
print(f"naive GET+SET admitted {n} of 200; Lua script admitted {a} of 200")
asyncio.run(main())
$ python race_test.py
naive GET+SET admitted 200 of 200; Lua script admitted 10 of 200
I ran it three times and got the same result each time. Redis runs a script atomically and blocks other commands while it executes, so the check and the update cannot interleave. MULTI/EXEC alone does not fix this, because the decision happens between the read and the write; you would need WATCH and retries, which is slower and messier than a script.
Step 6: Add FastAPI middleware with redis-py
register_script returns a callable that uses EVALSHA and reloads the script automatically if Redis answers NOSCRIPT, which happens after a restart or failover because the script cache is volatile.
python -m venv .venv && . .venv/bin/activate
pip install redis==8.1.0 fastapi==0.162.2 uvicorn==0.54.0
# app.py: FastAPI 0.162 + redis-py 8.1 token bucket middleware
import math
import os
from pathlib import Path
import redis.asyncio as redis
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
CAPACITY = 10 # burst
RATE = 5 # tokens per second (300 a minute sustained)
r = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"),
socket_timeout=0.05) # 50 ms: fail fast, not slow
# register_script uses EVALSHA and reloads the script on NOSCRIPT
token_bucket = r.register_script(Path("token_bucket.lua").read_text())
app = FastAPI()
def client_key(request: Request) -> str:
# Prefer an authenticated identity; fall back to IP for anonymous traffic
api_key = request.headers.get("x-api-key")
ident = f"key:{api_key}" if api_key else f"ip:{request.client.host}"
return f"rl:{{{ident}}}:tb" # {...} is the Redis Cluster hash tag
@app.middleware("http")
async def rate_limit(request: Request, call_next):
try:
allowed, remaining, retry_ms, reset_ms = await token_bucket(
keys=[client_key(request)], args=[CAPACITY, RATE, 1])
except redis.RedisError:
return await call_next(request) # fail open: Redis down != API down
headers = {
"RateLimit-Policy": f'"burst";q={CAPACITY};w={math.ceil(CAPACITY / RATE)}',
"RateLimit": f'"burst";r={remaining};t={math.ceil(reset_ms / 1000)}',
}
if not allowed:
headers["Retry-After"] = str(math.ceil(retry_ms / 1000))
return JSONResponse({"error": "rate_limited"}, status_code=429,
headers=headers)
response = await call_next(request)
response.headers.update(headers)
return response
@app.get("/hello")
async def hello():
return {"ok": True}
In production, hash the API key before putting it in a Redis key name, so a MONITOR session or a key dump does not leak credentials. Run it and send 12 requests:
uvicorn app:app --port 8011 &
for i in $(seq 1 12); do
curl -s -o /dev/null -w "%{http_code} " -H 'x-api-key: demo' localhost:8011/hello
done; echo
curl -si -H 'x-api-key: demo' localhost:8011/hello | head -6
Output from my run (the last status codes depend on loop timing, because the bucket refills 5 tokens a second):
200 200 200 200 200 200 200 200 200 200 429 429
HTTP/1.1 429 Too Many Requests
date: Tue, 10 Nov 2026 05:20:55 GMT
server: uvicorn
ratelimit-policy: "burst";q=10;w=2
ratelimit: "burst";r=0;t=2
retry-after: 1
The header syntax follows draft-ietf-httpapi-ratelimit-headers-11 (23 May 2026): RateLimit-Policy carries the quota q and window w in seconds, RateLimit carries remaining r and seconds to reset t. A bucket of 10 refilling at 5 per second is described as 10 per 2 seconds. It is still a draft, so treat the exact syntax as subject to change. 429 itself is RFC 6585, which says 429 responses must not be cached.
Step 7: Add Express middleware with ioredis
ioredis 6.0.0 (31 July 2026) requires Node.js 20 and defaults to RESP3; Lua tables of integers come back as plain arrays either way. defineCommand gives you EVALSHA with an automatic fallback.
npm install ioredis@6.0.0 express@5.3.0
// server.mjs: Express 5.3 + ioredis 6.0 sliding window middleware
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import express from "express";
import Redis from "ioredis";
const LIMIT = 100; // requests
const WINDOW_MS = 60_000; // per rolling minute
const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
commandTimeout: 50, // ms; fail fast instead of stalling requests
maxRetriesPerRequest: 1,
});
// defineCommand wraps EVALSHA with an automatic EVAL fallback
redis.defineCommand("slidingWindow", {
numberOfKeys: 1,
lua: readFileSync("sliding_window.lua", "utf8"),
});
const app = express();
app.set("trust proxy", 1); // only if exactly one proxy sits in front
app.use(async (req, res, next) => {
const id = req.get("x-api-key") ? `key:${req.get("x-api-key")}` : `ip:${req.ip}`;
let result;
try {
result = await redis.slidingWindow(`rl:{${id}}:sw`, WINDOW_MS, LIMIT, randomUUID());
} catch (err) {
return next(); // fail open; alert on this path
}
const [allowed, remaining, retryMs] = result;
res.set("RateLimit-Policy", `"minute";q=${LIMIT};w=${WINDOW_MS / 1000}`);
res.set("RateLimit", `"minute";r=${remaining};t=${Math.ceil(WINDOW_MS / 1000)}`);
if (!allowed) {
res.set("Retry-After", String(Math.ceil(retryMs / 1000)));
return res.status(429).json({ error: "rate_limited" });
}
next();
});
app.get("/hello", (req, res) => res.json({ ok: true }));
app.listen(3000);
node server.mjs &
for i in $(seq 1 102); do
curl -s -o /dev/null -w "%{http_code}\n" -H 'x-api-key: n1' localhost:3000/hello
done | sort | uniq -c
# 100 200
# 2 429
trust proxy matters: without it req.ip is your load balancer and every anonymous user shares one bucket; set it too loosely and clients choose their own IP through X-Forwarded-For.
Step 8: Load test the limiter with k6 or hey
A limiter you have not load tested is a guess. k6's constant-arrival-rate executor holds a fixed request rate regardless of response time, which is what you want here.
// k6-ratelimit.js: drive 20 req/s at one key for 30 s and count 429s
import http from "k6/http";
import { check } from "k6";
import { Counter } from "k6/metrics";
const limited = new Counter("rate_limited");
export const options = {
scenarios: {
one_key: {
executor: "constant-arrival-rate",
rate: 20, // iterations started per timeUnit
timeUnit: "1s",
duration: "30s",
preAllocatedVUs: 20,
maxVUs: 50,
},
},
thresholds: {
// 429 is marked expected below, so only other statuses count as failures
http_req_failed: ["rate<0.01"],
},
};
http.setResponseCallback(http.expectedStatuses(200, 429));
export default function () {
const res = http.get(`${__ENV.TARGET}/hello`, { headers: { "x-api-key": "k6" } });
if (res.status === 429) limited.add(1);
check(res, { "has RateLimit header": (r) => r.headers["Ratelimit"] !== undefined });
}
$ k6 run -q -e TARGET=http://localhost:8011 k6-ratelimit.js
✓ has RateLimit header
checks.........................: 100.00% ✓ 601 ✗ 0
http_reqs......................: 601 20.030491/s
rate_limited...................: 442 14.731243/s
The arithmetic checks out: a burst of 10 plus 5 per second for 30 seconds is about 160 allowed, and 601 minus 442 is 159. For a quick smoke test without k6, hey works: hey -z 30s -c 20 -q 1 -H "x-api-key: hey" http://localhost:8011/hello sends 20 workers at 1 request per second each and prints a status code histogram.
Step 9: Production checklist for Redis Cluster and failover
Scripts that touch two keys (a per-user and a per-tenant bucket, say) must keep both in one slot. On a throwaway three-node local cluster:
redis-cli -c -p 7101 EVAL "redis.call('INCR',KEYS[1]); return redis.call('INCR',KEYS[2])" \
2 rl:user:42:minute rl:global:minute
# (error) CROSSSLOT Keys in request don't hash to the same slot
redis-cli -c -p 7101 EVAL "redis.call('INCR',KEYS[1]); return redis.call('INCR',KEYS[2])" \
2 'rl:{user:42}:minute' 'rl:{user:42}:day'
# (integer) 1
redis-cli -p 7101 CLUSTER KEYSLOT 'rl:{user:42}:tb' # 15880
redis-cli -p 7101 CLUSTER KEYSLOT 'rl:{user:42}:sw' # 15880
A truly global limit cannot share a slot with every user, so check it in a second call or keep it local to each instance. The rest of the checklist:
- Failover loses a little state. Cluster replication is asynchronous, so a failover can drop recent writes. For a limiter that means a few seconds of generous limits. Accept it.
- Hot keys. One huge tenant on one key pins one shard. Split the bucket into N sub-keys and give each 1/N of the rate if a single key shows up in
redis-cli --hotkeys(requires an LFUmaxmemory-policy). - Timeouts and fail-open. Both middlewares use a 50 ms timeout and let traffic through on error. Emit a metric on that path and alert on it, or you will discover the limiter was off for a week.
- Redis Functions. Since Redis 7.0 you can
FUNCTION LOADthe same logic so it persists and replicates with the dataset instead of living in the volatile script cache. Clients then callFCALL. Worth it if many services share one limiter.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Limit is exceeded under load, more requests pass than configured | Read-then-write in app code, not one script | Move check and update into a single Lua script (Step 5) |
CROSSSLOT Keys in request don't hash to the same slot |
Script touches keys in different Cluster slots | Put the shared part of the key in {} hash tags |
NOSCRIPT No matching script |
Script cache cleared by restart, failover or SCRIPT FLUSH |
Use register_script / defineCommand, or catch NOSCRIPT and SCRIPT LOAD again |
| Every anonymous user is limited together | Keying on the load balancer IP | Configure trusted proxies (trust proxy, uvicorn --proxy-headers) so the real client IP is used |
Clients bypass limits by changing X-Forwarded-For |
Trusting the header from anyone | Trust only your own proxy hop count |
| Limits reset at odd times or keys never expire | Missing or wrong PEXPIRE, or units mixed (s vs ms) |
Keep every value in ms inside the script and set TTL on every write |
| Retry-After is 0 or negative | Rounding down ms to seconds | Use ceil(retry_ms / 1000) and never send less than 1 |
| Latency rises when Redis is slow | No client timeout, middleware waits | Set a 25 to 50 ms command timeout and fail open with a metric |
| One shard hot, others idle | One tenant on one key | Shard the tenant bucket into N keys with rate/N each |
| Rate limiter "works" locally, not behind nginx | nginx limit_req answering first with its default 503 |
Set limit_req_status 429 or remove the duplicate limit |
FAQ
Is Redis INCR with EXPIRE good enough for rate limiting?
For a fixed window, yes, if you do both in one script or set the TTL only when INCR returns 1. It is cheap and correct, but allows up to twice the limit across a window boundary. Use the token bucket script when bursts at the boundary matter.
Should I use Lua scripts or Redis Functions?
Lua scripts via EVALSHA are simplest and work on every Redis 7+, Valkey and most managed services. Redis Functions (Redis 7.0+) are loaded once, persisted and replicated with the data, which suits a limiter shared by many services. The Lua body is the same either way.
What happens to my limits if Redis fails over?
Redis Cluster replicates asynchronously, so the newest writes can be lost and some clients get a few extra requests for a few seconds. The script cache is also cleared, which client helpers handle by reloading. For abuse-sensitive endpoints like login, fail closed instead of open.
Does this work with Valkey?
Yes. The scripts use only TIME, HMGET, HSET, PEXPIRE, sorted set commands and SET with PX, all available in Valkey. If you are moving, see how to migrate Redis to Valkey.
When I add a limiter to a service, the first thing I check is the key: is it the authenticated identity, and is the IP the real client's. The second is the failure path, because a limiter that silently fails open for a week is just a slower API. Then I load test with k6 and compare allowed counts with the arithmetic, as in Step 8. Official references: the Redis scripting guide, the Redis Cluster specification, the IETF RateLimit header draft and the k6 executor docs.
Related: API Rate Limiting Explained (2026): Token Bucket, GCRA and 429s, Redis vs Valkey in 2026: Licenses, Valkey 9.1 vs Redis 8.10, Which to Pick and How to Fix LLM API 429 Rate Limit and 529 Overloaded Errors (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
API Rate Limiting Explained (2026): Token Bucket, GCRA and 429s
API rate limiting explained for 2026: fixed and sliding windows, token bucket, leaky bucket and GCRA compared, per-user vs per-IP keys, distributed limits in Redis, 429 with Retry-After, the IETF RateLimit header draft-11, and token-based limits for AI agent traffic.
12 minNov 10, 2026How to Find Slow Queries in Postgres: pg_stat_statements and EXPLAIN (2026)
Step-by-step guide to finding and fixing slow PostgreSQL queries on Postgres 18: enable pg_stat_statements, rank queries by total time, read EXPLAIN ANALYZE plans and buffers, add the right index concurrently, fix bad estimates and log slow plans with auto_explain.
12 minNov 10, 2026How to Fix Postgres 'Sorry, Too Many Clients Already' (2026)
Fix PostgreSQL FATAL: sorry, too many clients already and remaining connection slots are reserved: find who holds connections in pg_stat_activity, kill idle in transaction sessions, do the max_connections math, and deploy PgBouncer 1.26.1 in transaction mode.
12 min