I was on call when a new “shopping‑cart” microservice went live. Within minutes the API gateway started spitting **429 Too Many Requests** at every third call. The alarm bells weren’t a bug in the client‑side SDK – they were my own rate‑limiting middleware, mis‑configured to treat every user as a separate key. The result? A perfectly healthy backend got throttled by its own guardrail, and our SREs spent an hour digging through Redis‑stat logs to see why *all* traffic suddenly looked “burst‑y”.
That night taught me three hard lessons:
- a limiter is only as good as its cleanup strategy;
- atomicity matters more than you think when you’re counting across shards; and
- the latency you add per request must be measured, not guessed.
If you’ve ever wondered why your production rate limiter feels flaky, stick around. I’ll walk you through the algorithms, the architecture choices that matter in 2026, and a battle‑tested Go implementation that survives network partitions, data races, and the dreaded “key‑explosion” problem.
- Pick the right algorithm for your traffic pattern – token bucket for smoothing, sliding window for fairness.
- Use Redis 7.2+ Lua scripts to keep the limiter atomic and sub‑millisecond.
- Never rely on naive key expiration; clean old windows with SCAN and TTL tricks.
- Expose proper 429 responses with Retry‑After and custom back‑off headers.
- Instrument every path – Prometheus metrics, Grafana dashboards, and latency benchmarks (< 5 ms per call ).
Before you start: Go 1.21+, Redis 7.2+, Docker 27, Envoy 1.28 (or any compatible proxy), Prometheus 2.50+, Grafana 10, and a basic grasp of Lua scripting for Redis.
Rate limiter design: quick answer
A rate limiter controls request flow to protect system resources. From scratch, you choose an algorithm like token bucket or sliding window, implement it with atomic operations in a data store like Redis, and return proper HTTP 429 status codes. The design is critical for API fairness, preventing abuse, and ensuring application stability.
—
Understanding Core Rate Limiting Algorithms and Their Trade‑offs
Token Bucket vs. Leaky Bucket: A Deep Comparison
| Feature | Token Bucket | Leaky Bucket |
|---|---|---|
| Shape of traffic | Allows bursts up to bucket size | Smoothes bursts automatically |
| State stored | Tokens (count) | Water level (count) |
| Implementation | Simple INCR/DECR | Queue‑like, often with time‑based leak |
| Ideal use‑case | API quotas, client‑side smoothing | Bandwidth shaping, network‑level throttling |
A token bucket is the go‑to for most API quotas because you can let users fire a few requests in a row and then “pay back” with idle time. The leaky bucket, on the other hand, guarantees a constant outflow – perfect when downstream services can’t handle spikes at all.
In my experience, the token bucket’s simplicity hides a nasty edge case: **bucket overflow** when the refill logic runs faster than consumption. If you don’t cap the token count, a stale key can accumulate tokens forever, effectively disabling the limit. The fix is a single `MIN(bucket, maxTokens)` guard inside the Lua script (see code below).
Sliding Window Counters: The Production Champion? (Dive into Redis + Lua)
Sliding windows give you per‑second fairness without the “burst‑then‑drain” artifact of token buckets. The trick is to store a *sorted set* (`ZSET`) of timestamps per client and prune entries older than the window. Redis 7.2 introduced the `ZPOPMIN` command with a count argument, making the cleanup cheap.
Why do most 2024‑2026 production systems prefer sliding windows?
- **Exactness:** No approximation; you truly enforce “N requests per minute”.
- **Burst handling:** You can still allow a few rapid calls as long as the total stays under the limit.
- **Observability:** You can query the current count without extra bookkeeping.
The downside is memory: each request creates a member in a ZSET. For high‑cardinality keys (think millions of users) you’ll see memory bloat unless you rotate keys aggressively. The pattern I use is:
- **Hash key** = `rl:{clientID}:{period}` (e.g., `rl:12345:60`).
- **ZADD** the current epoch ms with a dummy payload.
- **ZREMRANGEBYSCORE** to drop entries older than `windowSize`.
- **ZCARD** to get the count.
All four steps can be wrapped in a single Lua script, guaranteeing atomicity and keeping the round‑trip count at 1.
// go.mod
module limiter
go 1.21
require (
github.com/go-redis/redis/v9 v9.3.2
)
// limiter.go
package limiter
import (
"context"
"fmt"
"time"
"github.com/go-redis/redis/v9"
)
// Redis Lua script for sliding window
// redis-cli --eval sliding_window.lua , <clientID> <limit> <windowMs>
var slidingWindowScript = redis.NewScript(`
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- Remove stale entries
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- Current count
local count = redis.call('ZCARD', key)
if count >= limit then
return {0, count}
end
-- Add current request
redis.call('ZADD', key, now, now)
-- Set TTL to at least window size (helps cleanup)
redis.call('PEXPIRE', key, window)
return {1, count + 1}
`)
// Limiter is a simple sliding‑window implementation.
type Limiter struct {
rdb *redis.Client
limit int
win time.Duration
}
// NewLimiter creates a new limiter instance.
func NewLimiter(rdb *redis.Client, limit int, win time.Duration) *Limiter {
return &Limiter{rdb: rdb, limit: limit, win: win}
}
// Allow checks if a request from clientID should pass.
func (l *Limiter) Allow(ctx context.Context, clientID string) (bool, int, error) {
key := fmt.Sprintf("rl:%s:%d", clientID, int(l.win.Seconds()))
now := time.Now().UnixMilli()
// Execute Lua atomically
res, err := slidingWindowScript.Run(ctx, l.rdb, []string{key},
l.limit, int(l.win.Milliseconds()), now).Result()
if err != nil {
return false, 0, fmt.Errorf("redis script error: %w", err)
}
// Result is a two‑element array: {allowed, count}
vals := res.([]interface{})
allowed := vals[0].(int64) == 1
count := int(vals[1].(int64))
return allowed, count, nil
}
A few things to notice:
- **All error handling is concrete** – we wrap Redis errors with context.
- **`PEXPIRE`** guarantees the key disappears after the window, avoiding a flood of stale keys.
- **The script returns the current count**, useful for `RateLimit‑Remaining` headers.
**My take:** If you’re building a public API that must treat every tenant fairly, start with sliding windows. Token buckets are fine for internal services where burst‑tolerance is a feature, not a liability.
Tip: Use Redis 7.2’s ZRANGE with the WITHSCORES option to debug how many timestamps you actually store per key.
—
System Design: Architecture, Libraries, and Response Strategies
Client‑Side, Server‑Side, or Distributed? Picking Your Architectural Pillar
| Layer | Pros | Cons |
|---|---|---|
| **Client‑side** (SDK) | Immediate feedback, no network hop for limit check | Requires trust, easy to bypass |
| **Server‑side (gateway)** | Central point, easy to enforce | Becomes a bottleneck, needs scaling |
| **Distributed service** | Fine‑grained control, reusable across services | Adds latency, requires cluster coordination |
In 2026 most large‑scale platforms adopt a **hybrid** approach: a lightweight check in the API gateway (Envoy 1.28 or Istio 1.12) that proxies to a dedicated *Rate‑Limit Service* when the token bucket is near exhaustion. This pattern lets the gateway return a 429 instantly for obvious over‑limit cases, while the service handles complex sliding‑window logic and dynamic scaling.
I’ve seen teams try to shove the limiter into every microservice, only to end up with “duplicate key” race conditions and tangled retry logic. The **single‑source‑of‑truth** approach (dedicated service + Redis) keeps the codebase lean.
Essential Libraries and Frameworks (2024 Edition)
- **Go:** `github.com/go-redis/redis/v9` – modern client with context support.
- **Lua:** Built‑in Redis scripting engine (5.1).
- **Envoy:** `ngx_http_limit_req_module` equivalent is `envoy.filters.http.ratelimit`.
- **Istio:** Uses the same filter but integrates with Mixer for telemetry.
- **Docker:** Containerize the limiter service for easy rollout (`docker run -p 8080:8080 limiter:latest`).
- **Prometheus & Grafana:** Export `limiter_requests_total`, `limiter_allowed_total`, and latency histograms.
You can also look at the **Rate Limiter Design: 5 Patterns That Scale (2026)** post for a quick cheat‑sheet on which pattern fits which use‑case.
Implementing Graceful Responses: Quotas, Delays, and Backoff Headers
When a request is denied, the client should know *when* to try again. The HTTP 429 spec (RFC 6585) recommends a `Retry‑After` header, but production systems add more context:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Reset: 1696575600
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Backoff: exponential
- **`Retry-After`** – seconds until the next token appears.
- **`X-RateLimit-Reset`** – epoch timestamp of the window reset.
- **`X-RateLimit-Backoff`** – tells the client whether to use linear or exponential back‑off.
In Go you can add a tiny helper:
func writeRateLimitHeaders(w http.ResponseWriter, limit, remaining int, reset time.Time) {
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(reset.Unix(), 10))
w.Header().Set("Retry-After", strconv.Itoa(int(time.Until(reset).Seconds())))
w.Header().Set("X-RateLimit-Backoff", "exponential")
}
—
Building Robust Production Code in Go with Real Error Handling
Practical Go Implementation with All Edge Cases
Below is a *complete* HTTP handler that wires the sliding‑window limiter into a gRPC‑compatible JSON API. It demonstrates:
- Context propagation
- Connection pooling (`redis.NewClient`) with `MaxRetries` set
- Distinguishing Redis `LOADING` vs. network timeout errors
- Graceful fallback to **fail‑open** when the limiter is unavailable (a conscious trade‑off)
// main.go
// go 1.21
package main
import (
"context"
"log"
"net/http"
"strconv"
"time"
"github.com/go-redis/redis/v9"
"github.com/gorilla/mux"
"myapp/limiter"
)
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "redis:6379",
Password: "", // no password set
DB: 0,
MinRetries: 1,
MaxRetries: 3,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolSize: 20,
})
// Verify connectivity at startup
if err := rdb.Ping(context.Background()).Err(); err != nil {
log.Fatalf("cannot connect to Redis: %v", err)
}
lim := limiter.NewLimiter(rdb, 1000, time.Minute)
router := mux.NewRouter()
router.HandleFunc("/api/v1/resource", func(w http.ResponseWriter, r *http.Request) {
clientID := r.Header.Get("X-Client-ID")
if clientID == "" {
http.Error(w, "missing client ID", http.StatusBadRequest)
return
}
allowed, cnt, err := lim.Allow(r.Context(), clientID)
if err != nil {
// Distinguish Redis outage vs. script error
if err == redis.ErrClosed || err == context.DeadlineExceeded {
// Fail‑open: let the request through but log heavily
log.Printf("[WARN] Redis unavailable (%v), bypassing limiter", err)
// Proceed to business logic
} else {
log.Printf("[ERROR] Limiter failed: %v", err)
http.Error(w, "internal limiter error", http.StatusInternalServerError)
return
}
}
if !allowed {
reset := time.Now().Add(time.Minute - time.Since(time.Now().Truncate(time.Minute)))
limiter.WriteRateLimitHeaders(w, lim.limit, 0, reset)
http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
// Normal path – add headers indicating remaining quota
remaining := lim.limit - cnt
limiter.WriteRateLimitHeaders(w, lim.limit, remaining, time.Now().Add(time.Minute))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}).Methods(http.MethodGet)
srv := &http.Server{
Addr: ":8080",
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Println("Rate‑limit service listening on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
Key takeaways from the snippet:
- **`MaxRetries`** caps retry storms during a brief network hiccup.
- **Fail‑open** is deliberate – during a Redis outage we prefer *availability* over *perfect fairness*.
- **Headers** are added *before* writing the body, keeping the response compliant with HTTP/2 pipelining.
**Warning:** Never ignore a Redis `LOADING` error. It means the cluster is rebalancing; returning 429 blindly would hide a systemic issue.
Writing a Polished Distributed Rate Limiter Client
If your microservices consume the limiter over gRPC, you’ll need a thin client that handles back‑off and retries. Below is a minimal **client** that respects `Retry‑After`:
// client.go
// go 1.21
package limiterclient
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
type LimiterClient struct {
httpClient *http.Client
endpoint string
}
// New creates a new HTTP‑based limiter client.
func New(endpoint string) *LimiterClient {
return &LimiterClient{
httpClient: &http.Client