Rate Limiting in Go with Redis and Lua: A Token Bucket That Doesn't Race

A rate limiter looks easy until you run more than one instance of your service. Two requests hit two servers at the same moment, both read the counter, both see room, both proceed, and your limit is now a suggestion. The fix is to make the check and the update a single atomic step. In Redis, the tool for that is a Lua script.
I use this pattern in an open-source Go API gateway I built, which also handles JWT authentication and CORS. You can read the full project on GitHub. This post walks through the rate limiter from scratch.
Why the naive version breaks
The obvious implementation reads a counter, compares it to the limit, and increments it. Between the read and the write, another request can do the same thing. Under concurrency, more requests get through than you allow. Wrapping commands in MULTI/EXEC does not help, because a transaction cannot branch on a value it has just read.
Pick an algorithm: token bucket
A token bucket holds up to capacity tokens and refills at a steady rate. Each request spends a token. If the bucket is empty, the request is rejected. This allows a short burst (up to the capacity) while enforcing an average rate, which matches how real clients behave. It also needs only two values per key: the current token count and the last refill time.
The Lua script
The script computes how many tokens have refilled since the last request, decides whether to allow this one, and writes the new state, all in one atomic step. It uses the Redis server clock (TIME) instead of the caller's, so clock differences between your app servers cannot skew the result.
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_per_sec = tonumber(ARGV[2])
local requested = 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])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now
end
local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + (elapsed / 1000) * refill_per_sec)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call("HSET", key, "tokens", tokens, "ts", now)
-- expire idle keys after twice the time it takes to refill fully
redis.call("PEXPIRE", key, math.ceil(capacity / refill_per_sec * 1000) * 2)
return { allowed, math.floor(tokens) }The Go wrapper
This uses go-redis v9. The script is embedded into the binary and loaded once. Run calls it by hash and falls back to sending the full script if Redis has not seen it yet.
package ratelimit
import (
"context"
_ "embed"
"github.com/redis/go-redis/v9"
)
//go:embed token_bucket.lua
var tokenBucketSrc string
type Limiter struct {
rdb *redis.Client
script *redis.Script
capacity int
refillPerSec float64
}
func New(rdb *redis.Client, capacity int, refillPerSec float64) *Limiter {
return &Limiter{
rdb: rdb,
script: redis.NewScript(tokenBucketSrc),
capacity: capacity,
refillPerSec: refillPerSec,
}
}
// Allow spends one token for key. It returns whether the request is allowed
// and how many tokens remain.
func (l *Limiter) Allow(ctx context.Context, key string) (bool, int, error) {
res, err := l.script.Run(ctx, l.rdb, []string{"rl:" + key}, l.capacity, l.refillPerSec, 1).Int64Slice()
if err != nil {
return false, 0, err
}
return res[0] == 1, int(res[1]), nil
}HTTP middleware
Wrap your handlers so every request is checked. Setting Retry-After tells well-behaved clients when to come back.
package ratelimit
import (
"net"
"net/http"
"strconv"
)
func Middleware(l *Limiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
ok, remaining, err := l.Allow(r.Context(), ip)
if err != nil {
// Redis unavailable: fail open so the limiter can't take the API down.
next.ServeHTTP(w, r)
return
}
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
if !ok {
w.Header().Set("Retry-After", "1")
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}Failure modes to decide on
- Redis is down. Failing open keeps your API up but unprotected. For login, OTP or payment routes, consider failing closed or falling back to a small in-memory limit per instance.
- Which key to use. Prefer user ID or API key on authenticated routes. For IPs behind a load balancer, read the forwarded header only if your proxy sets it, otherwise clients can spoof it.
- Redis Cluster. A script may only touch keys in the same hash slot. This limiter uses a single key per client, so it works as is.
- Script cost. Redis runs scripts one at a time. Keep them constant-time, as this one is.
Test it under concurrency
The whole point is correctness under concurrent load, so test exactly that. Fire more requests than the capacity at the same key from many goroutines and assert that no more than capacity succeed. If you swap the Lua script for a naive read-then-write, this test will fail, which is a good way to prove the atomicity is real.
func TestAllowIsAtomic(t *testing.T) {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
l := New(rdb, 10, 0.001) // capacity 10, almost no refill
var allowed atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if ok, _, _ := l.Allow(context.Background(), "test-key"); ok {
allowed.Add(1)
}
}()
}
wg.Wait()
if got := allowed.Load(); got > 10 {
t.Fatalf("allowed %d requests, limit is 10", got)
}
}Frequently asked questions
Why use a Lua script instead of MULTI/EXEC?
MULTI/EXEC runs commands as a batch but cannot make decisions based on values it just read. A rate limiter has to read the current tokens, decide, and write back. Lua does that atomically.
Token bucket or sliding window?
A token bucket allows short bursts up to its capacity and then refills steadily, which suits most APIs. Sliding windows are stricter about the exact count in the last N seconds but cost more to store and compute.
Does a Lua script block Redis?
Yes. Redis runs one script at a time, so keep scripts short and constant-time. The token bucket here does a handful of operations on a single key, which is fine.
Should the limiter fail open or fail closed if Redis is down?
For most public APIs, fail open: an outage in the limiter should not take your API down with it. For login or payment endpoints, consider failing closed or falling back to a local in-memory limit.
What should I rate limit by?
Use the strongest identity you have: user ID or API key for authenticated routes, and IP address for anonymous ones. Behind a proxy, only trust the forwarded IP header if the proxy sets it.