How to Implement Rate Limiting with Redis: Token Bucket in Lua (2026)

Nov 10, 2026 · 15 min · Ajay Kumar

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 LFU maxmemory-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 LOAD the same logic so it persists and replicates with the dataset instead of living in the volatile script cache. Clients then call FCALL. Worth it if many services share one limiter.
Rate limit middleware: one Redis round trip per request Requestx-api-key or client IP Derive keyrl:{key:abc}:tb EVALSHA scriptatomic check and update Redis errorfail open + metric allowed = 1allowed = 0 Pass to handler add RateLimit-Policy: q, wadd RateLimit: r, t 429 Too Many Requests Retry-After: seconds to waitRateLimit headers, no caching Key, algorithm and limits are configuration; the only shared state is one Redis key per identity.
Request flow of the FastAPI and Express middleware in this guide; header names follow draft-ietf-httpapi-ratelimit-headers-11 and RFC 6585.

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