← All posts
Security

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

By Sayan Mondal2 min read
Rate Limiting in Go

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.

token_bucket.lualua
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.

limiter.gogo
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.

middleware.gogo
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.

limiter_test.gogo
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.