Your API is live, traffic is climbing, and suddenly a single client makes 10,000 requests in five seconds. Without rate limiting in Go, your database becomes a bottleneck, legitimate users face timeouts, and your infrastructure costs spike. Rate limiting in Go: a practical guide for developers covers the tools and patterns your team needs to protect endpoints, maintain SLA compliance, and keep systems stable under load.
Why rate limiting matters in production
Rate limiting is not optional once you're running a service that others depend on. A misconfigured client, a runaway bot, or a malicious actor can starve resources from genuine users. In distributed systems, the problem compounds—without coordination across instances, each server enforces its own limits independently, allowing an attacker to work around your defenses by distributing requests.
We've seen this play out in real production incidents. A partner's data sync job didn't implement backoff logic and hammered the API with retry storms. A web scraper discovered an unprotected endpoint and consumed 40% of monthly database quota in an afternoon. Rate limiting sits at the perimeter of your API, catching these issues before they cascade.
Token bucket—golang.org/x/time/rate implements it
The token bucket algorithm is the industry standard for rate limiting. The idea is simple: imagine a bucket that holds a fixed number of tokens. Each request consumes one token. Tokens refill at a steady rate. When the bucket is empty, requests are rejected.
Go's standard library provides golang.org/x/time/rate, which implements token bucket natively. Here's a basic example:
package main
import (
"fmt"
"golang.org/x/time/rate"
"time"
)
func main() {
// Allow 10 requests per second, with burst of 20
limiter := rate.NewLimiter(rate.Limit(10), 20)
for i := 1; i <= 25; i++ {
if limiter.Allow() {
fmt.Printf("Request %d allowed\n", i)
} else {
fmt.Printf("Request %d rejected\n", i)
}
}
}
rate.NewLimiter(rate.Limit(10), 20) creates a limiter that allows 10 tokens per second with a burst capacity of 20. The burst accounts for legitimate traffic spikes—a user might send 5 requests rapidly, then nothing for a while. The burst capacity lets this through without rejection, but sustained overages are still caught.
In a real API handler, you'd check Allow() and return a 429 if it fails:
func handleRequest(w http.ResponseWriter, r *http.Request, limiter *rate.Limiter) {
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
// Process the request
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
}
This approach works well for single-instance APIs or when you want simple, per-application limits. The downside: it doesn't coordinate across multiple servers.
Per-client limiting—map of limiters keyed by user or IP
A single rate limit for all traffic is too coarse. One noisy client shouldn't affect others. Instead, we create a limiter per client—keyed by user ID, API key, or IP address.
Here's a practical middleware approach that tracks limits by client IP:
package main
import (
"golang.org/x/time/rate"
"net"
"net/http"
"sync"
)
type ClientLimiters struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
}
func NewClientLimiters() *ClientLimiters {
return &ClientLimiters{
limiters: make(map[string]*rate.Limiter),
}
}
func (cl *ClientLimiters) GetLimiter(ip string) *rate.Limiter {
cl.mu.RLock()
limiter, exists := cl.limiters[ip]
cl.mu.RUnlock()
if exists {
return limiter
}
cl.mu.Lock()
defer cl.mu.Unlock()
// Check again to avoid race condition
if limiter, exists := cl.limiters[ip]; exists {
return limiter
}
limiter = rate.NewLimiter(rate.Limit(10), 20)
cl.limiters[ip] = limiter
return limiter
}
func (cl *ClientLimiters) RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
limiter := cl.GetLimiter(ip)
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
cl := NewClientLimiters()
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("here is your data"))
})
http.ListenAndServe(":8080", cl.RateLimitMiddleware(mux))
}
This map-based approach isolates clients from each other. Client A hitting the limit doesn't affect Client B's quota. The sync.RWMutex handles concurrent map access safely.
In practice, you might key by user ID (for authenticated endpoints) or API key instead of IP. For e-commerce platforms, we've keyed by user ID to allow one user's mobile app and web browser to share the same budget. For public endpoints, IP-based limits work, though be careful behind proxies—you need to extract the real client IP from X-Forwarded-For headers.
Distributed rate limiting—Redis-based limits for many instances
The map approach breaks down when you have multiple API servers. Each instance maintains its own limiter state, so a client can distribute requests across servers and bypass the limit entirely. A client sending 5 requests per second to each of three servers gets 15 requests through, even though the global limit is 10.
Redis solves this by centralizing the token bucket state. A popular library for this is redis-cell, which implements a sliding-window rate limiter in Lua on the Redis side, but we can also use straightforward Redis operations:
package main
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
type RedisLimiter struct {
client *redis.Client
rate int64 // tokens per second
burst int64 // max burst capacity
window time.Duration
}
func NewRedisLimiter(client *redis.Client, rate, burst int64) *RedisLimiter {
return &RedisLimiter{
client: client,
rate: rate,
burst: burst,
window: time.Second,
}
}
func (rl *RedisLimiter) Allow(ctx context.Context, clientID string) (bool, error) {
key := fmt.Sprintf("ratelimit:%s", clientID)
// Use Redis INCR with TTL to track requests in a window
now := time.Now().Unix()
windowKey := fmt.Sprintf("%s:%d", key, now)
pipe := rl.client.Pipeline()
pipe.Incr(ctx, windowKey)
pipe.Expire(ctx, windowKey, 2*time.Second)
results, err := pipe.Exec(ctx)
if err != nil {
return false, err
}
count, err := results[0].(*redis.IntCmd).Val(), results[0].(*redis.IntCmd).Err()
if err != nil {
return false, err
}
allowed := count <= rl.burst
return allowed, nil
}
func main() {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
limiter := NewRedisLimiter(client, 10, 20)
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
allowed, err := limiter.Allow(r.Context(), ip)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !allowed {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
w.Write([]byte("data"))
})
http.ListenAndServe(":8080", mux)
}
With Redis backing the limiter, all instances query the same source of truth. Requests are now genuinely rate-limited across your entire fleet. This scales to thousands of concurrent clients, though Redis itself becomes a potential bottleneck—ensure it has adequate capacity and consider Redis Cluster for higher throughput.
Return 429 and include Retry-After headers
When rate limiting kicks in, the HTTP status code matters. 429 Too Many Requests is the standard response. It tells clients that they've hit a limit, not that something is broken.
The Retry-After header tells the client when to try again. Include it in every rate-limit response:
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
Retry-After can be a number of seconds (1, 5, 60) or an HTTP date. Use seconds for simple cases. Clients that respect this header will back off automatically, reducing wasted requests.
Some teams also include additional headers to help debugging:
w.Header().Set("X-RateLimit-Limit", "100")
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(resetTime.Unix(), 10))
w.Header().Set("Retry-After", "5")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
These headers let clients track their quota in real-time and adjust behavior proactively. A thoughtful client sees "Remaining: 2" and knows to slow down before hitting zero.
Common mistakes
- Ignoring burst capacity. Setting burst too low (burst = rate) causes legitimate traffic spikes to fail. Users opening your app and making a few quick requests should succeed. A burst of 2–3 times your sustained rate is typical.
- Rate limiting on the wrong key. IP-based limits fail behind proxies or load balancers. Always extract the real client IP (X-Forwarded-For) or, for authenticated endpoints, use user ID or API key.
- Not handling clock skew in distributed systems. If your servers' clocks drift, Redis-based limits can behave unexpectedly. Keep NTP synchronized across the fleet.
- Blocking legitimate use cases. Some partners need higher limits for batch jobs. Provide a mechanism to grant elevated limits to trusted callers, keyed by API key rather than IP.
- Forgetting to clean up old limiters. The map-based approach can leak memory if you never evict unused client limiters. Consider a cleanup goroutine that removes stale entries every hour.
Key takeaways
- Token bucket via
golang.org/x/time/rateis the standard algorithm. It handles burst traffic gracefully while enforcing sustained limits. - Per-client limiting (by IP, user ID, or API key) prevents one noisy client from affecting others. Use a map of limiters with proper synchronization.
- For distributed systems, move state to Redis. Single-instance maps don't prevent clients from distributing requests across servers.
- Always respond with 429 and include Retry-After headers. This communicates intent clearly and lets well-behaved clients back off automatically.
- Test your rate limits under load. A limit that works in development may behave differently when traffic patterns shift or requests arrive in bursts.






Comments
Loading comments…