A production-grade HTTP rate limiter in Go with a sliding window algorithm. Supports both in-memory (single server) and Redis-backed (distributed, multi-server) operation.
Built as a Go learning project — going from zero Go experience to a working distributed system.
- Sliding window algorithm — fairer than fixed windows, no boundary bursts
- Per-key limiting — each IP (or any key) gets its own independent window
- Two backends — swap between in-memory and Redis with one line
- HTTP middleware — drop into any
net/httpserver - Atomic Redis operations — Lua scripting prevents race conditions under concurrent load
- Background cleanup — idle keys are automatically evicted (in-memory mode)
- Fail-open — if Redis goes down, requests are allowed rather than taking your app down
rate-limiter/
├── go.mod
├── main.go
└── limiter/
├── limiter.go # core sliding window logic (single key)
├── multi.go # multi-key in-memory limiter + background cleanup
├── middleware.go # net/http middleware + Limiter interface
└── redis.go # Redis-backed distributed limiter
- Go 1.21+
- A Redis instance (for distributed mode)
- Upstash — free cloud Redis, no installation needed
- Or Docker:
docker run -d -p 6379:6379 redis
1 — Clone and install dependencies
git clone https://github.com/yourusername/rate-limiter
cd rate-limiter
go mod tidy2 — Run in-memory mode (no Redis needed)
In main.go, use NewMultiLimiter:
rl := limiter.NewMultiLimiter(5, 10*time.Second)go run .3 — Run in distributed mode (Redis)
In main.go, use NewRedisLimiter:
rl := limiter.NewRedisLimiter("rediss://default:password@host.upstash.io:6379", 5, 10*time.Second)go run .import (
"net/http"
"time"
"rate-limiter/limiter"
)
func main() {
// 5 requests per 10 seconds per IP
ml := limiter.NewMultiLimiter(5, 10*time.Second)
// Start background goroutine — sweeps every minute, evicts keys idle for 5 minutes
ml.StartCleanup(1*time.Minute, 5*time.Minute)
defer ml.Stop()
myHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello!"))
})
http.Handle("/", limiter.Middleware(ml, myHandler))
http.ListenAndServe(":8080", nil)
}// Drop-in replacement for NewMultiLimiter — same Middleware works with both
rl := limiter.NewRedisLimiter("rediss://default:password@host.upstash.io:6379", 5, 10*time.Second)
http.Handle("/", limiter.Middleware(rl, myHandler))The Limiter interface has one method — Allow(key string) bool. You can call it with any string as the key:
// In your own handler or middleware
userID := r.Header.Get("X-User-ID")
if !myLimiter.Allow(userID) {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}Every request is stored as a timestamp. On each new request, timestamps older than the window are evicted, and the remaining count is checked against the limit. The window "slides" with every request — unlike a fixed window, there are no boundary bursts.
Timeline: ──────────────────────────────────────────▶
Requests: ● ● ● ● ●
Window: [──── 1s ────] ← slides with every request
▲ now
Each key gets its own RateLimiter (a slice of timestamps protected by a sync.Mutex). A sync.RWMutex protects the key map — concurrent reads don't block each other, only writes do. A background goroutine sweeps idle keys on a configurable interval.
Each key maps to a Redis sorted set where every member is a request timestamp (score = milliseconds). On each request, a Lua script atomically:
- Removes all entries older than the window start (
ZREMRANGEBYSCORE) - Counts what's left (
ZCARD) - If under the limit, records the new request (
ZADD) and sets a TTL (PEXPIRE)
Running this as a Lua script means it executes atomically inside Redis — no race conditions between the count check and the write, even under heavy concurrent load.
Both backends satisfy the same interface:
type Limiter interface {
Allow(key string) bool
}The middleware accepts any Limiter — swap backends without touching your handler code.
| Parameter | Description | Example |
|---|---|---|
limit |
Max requests allowed per window | 5 |
window |
Length of the sliding window | 10 * time.Second |
| Parameter | Description | Example |
|---|---|---|
interval |
How often to run the sweep | 1 * time.Minute |
maxIdle |
How long a key can go unused before eviction | 5 * time.Minute |
| Parameter | Description | Example |
|---|---|---|
redisURL |
Full Redis connection URL | "rediss://default:pass@host:6379" |
limit |
Max requests allowed per window | 5 |
window |
Length of the sliding window | 10 * time.Second |
Fire 7 requests at a server limited to 5 per 10 seconds:
Mac / Linux:
for i in $(seq 1 7); do curl -s http://localhost:8080/; doneWindows (PowerShell):
for ($i = 1; $i -le 7; $i++) { curl -s http://localhost:8080/ }Expected output:
hello! request allowed.
hello! request allowed.
hello! request allowed.
hello! request allowed.
hello! request allowed.
rate limit exceeded, slow down
rate limit exceeded, slow down
Why sliding window over fixed window? Fixed windows allow burst traffic at window boundaries — a client can send N requests just before the window resets and N more just after, effectively doubling the allowed rate. Sliding windows prevent this entirely.
Why Lua for Redis? Three separate Redis calls (remove → count → add) have a race window between the count read and the write. Two concurrent requests could both read count = 4 under a limit of 5, both add themselves, and both get through — breaking the limit. A Lua script runs atomically: nothing else executes inside Redis between the count and the add.
Why fail-open on Redis errors?
If Redis becomes unavailable, fail-closed (blocking all requests) takes your application down with it. Fail-open keeps the app running while you fix Redis — a better tradeoff for most production scenarios. Swap return true to return false in redis.go if your use case requires the opposite.
Why RWMutex in MultiLimiter?
Most operations on the key map are reads (looking up whether a key exists). A plain Mutex would serialise all reads, making them wait in line unnecessarily. RWMutex allows concurrent reads — only writes (adding new keys) require exclusive access.
This project is part of a two-project Go learning series. The next project is a Messaging System — a pub/sub broker using Go channels, goroutines, and fan-out patterns. Everything built here (interfaces, goroutines, channels, select) carries straight over.
MIT