How to Build a Rate Limiter in Go for Beginners
A beginner-friendly guide to rate limiter system design in Go. Explains token bucket, fixed window, and sliding window algorithms with runnable code, middleware, race-condition pitfalls, and production tradeoffs.
Key Takeaways
- Rate limiting protects your service from melting down by capping how fast any single client or IP can send requests.
- Token bucket is the best default for Go services: it handles sudden bursts gracefully while enforcing a predictable long-term speed limit.
- Always protect shared rate-limiter state with a mutex. HTTP handlers in Go run across parallel goroutines. Unsynchronized reads will quietly corrupt your counters.
- Return HTTP 429 with standard headers: send
Retry-After,X-RateLimit-Limit, andX-RateLimit-Remainingso honest clients can back off cleanly.- In-memory limiters work great for a single server. When you scale horizontally behind a load balancer, switch to Redis with atomic Lua scripts.
What is rate limiting?
Think of rate limiting like the bouncer at a popular club.
The bouncer does not care who you are or whether your ID is valid (that is authentication). The bouncer only cares about one thing: the room is full, so you have to wait outside for a minute.
In plain English: rate limiting caps how many requests a client, IP, or API key can make within a specific time window.
Without a limiter, one misconfigured loop or buggy script from a single client can overwhelm your database and take down your entire application for all other users.
When you reject an overloaded request, the HTTP standard is clear: return HTTP 429 Too Many Requests along with a Retry-After header telling the client when they can try again.
1. The Fixed Window Counter: Simple but imperfect
The fixed window algorithm is the simplest approach.
You divide time into clean 1-minute blocks. You keep a counter. Every request increments the counter. If the counter passes the limit, you say no until the clock strikes the next minute.
package ratelimit
import (
"sync"
"time"
)
type FixedWindowLimiter struct {
limit int
window time.Duration
count int
windowStart time.Time
mu sync.Mutex
}
func NewFixedWindowLimiter(limit int, window time.Duration) *FixedWindowLimiter {
return &FixedWindowLimiter{
limit: limit,
window: window,
windowStart: time.Now(),
}
}
func (f *FixedWindowLimiter) Allow() bool {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now()
if now.Sub(f.windowStart) >= f.window {
f.count = 0
f.windowStart = now
}
if f.count >= f.limit {
return false
}
f.count++
return true
}
The catch with fixed windows: The boundary burst
Here is what trips people up: fixed windows have a blind spot at the edges.
Imagine your limit is 10 requests per minute. A client sends 10 requests at 11:59:55 AM, and another 10 requests at 12:00:05 PM.
Both batches are legal inside their individual minute windows. But across that 10-second span, your server just took 20 requests (twice your maximum allowed speed!).
Use fixed windows when you want minimal code for internal scripts or low-traffic admin endpoints. For customer-facing APIs, you want something smarter.
2. The Token Bucket: The gold standard
Think of a token bucket like a water dispenser at a gym:
- Water drips into the dispenser at a constant speed (refill rate).
- The dispenser has a maximum capacity (bucket size).
- Every time a visitor wants a glass of water, they take a cup (consume 1 token).
- If the dispenser is dry, you have to wait for it to drip more water before drinking.
This model is wonderful for web services because it naturally allows controlled bursts. If a user has been idle, their bucket is full, so they can load 5 assets quickly. Once empty, they are throttled to the steady drip rate.
package ratelimit
import (
"sync"
"time"
)
type TokenBucket struct {
tokens float64
maxTokens float64
refillRate float64 // tokens per second
lastRefillTime time.Time
mu sync.Mutex
}
func NewTokenBucket(maxTokens, refillRate float64) *TokenBucket {
return &TokenBucket{
tokens: maxTokens,
maxTokens: maxTokens,
refillRate: refillRate,
lastRefillTime: time.Now(),
}
}
func (t *TokenBucket) refill() {
now := time.Now()
duration := now.Sub(t.lastRefillTime).Seconds()
t.tokens += duration * t.refillRate
if t.tokens > t.maxTokens {
t.tokens = t.maxTokens
}
t.lastRefillTime = now
}
func (t *TokenBucket) Allow() bool {
t.mu.Lock()
defer t.mu.Unlock()
t.refill()
if t.tokens >= 1 {
t.tokens--
return true
}
return false
}
Notice how we refill tokens lazily inside Allow() based on elapsed time. We do not need a background timer goroutine ticking every millisecond. The math handles it on demand.
3. Building the Go HTTP Middleware
Now let’s wire this into a real HTTP server. We will create an IPRateLimiter that maintains a token bucket per client IP address.
package middleware
import (
"net"
"net/http"
"sync"
"yourproject/ratelimit"
)
type IPRateLimiter struct {
limiters map[string]*ratelimit.TokenBucket
mu sync.Mutex
}
func NewIPRateLimiter() *IPRateLimiter {
return &IPRateLimiter{
limiters: make(map[string]*ratelimit.TokenBucket),
}
}
func (i *IPRateLimiter) GetLimiter(ip string) *ratelimit.TokenBucket {
i.mu.Lock()
defer i.mu.Unlock()
limiter, exists := i.limiters[ip]
if !exists {
// Bucket capacity 5, refills at 1 token per second
limiter = ratelimit.NewTokenBucket(5, 1.0)
i.limiters[ip] = limiter
}
return limiter
}
func RateLimit(ipLimiter *IPRateLimiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(w, "Invalid IP", http.StatusInternalServerError)
return
}
limiter := ipLimiter.GetLimiter(ip)
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
w.Header().Set("X-RateLimit-Limit", "5")
w.Header().Set("X-RateLimit-Remaining", "0")
http.Error(w, "Rate Limit Exceeded", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
The subtle production bug: Memory leaks in map storage
Here is the subtle bug that catches engineers in production: maps grow forever.
If 100,000 unique IP addresses hit your service over a week, your limiters map now holds 100,000 token bucket pointers in memory, even if 99% of those IPs never visit again.
In production, you must periodically clean up stale IP limiters that haven’t been accessed for over an hour, or cap the map using an LRU cache.
When to use which algorithm
| Goal | Best Choice |
|---|---|
| Understand the fundamentals | Fixed window counter |
| Handle bursty web traffic gracefully | Token bucket |
| Prevent edge bursts with strict mathematical limits | Sliding window counter |
| Single-server microservice | In-memory token bucket + sync.Mutex |
| Multi-instance cluster behind load balancer | Token bucket via Redis + Lua script |
Summary
Rate limiting is not about punishing users. It is about keeping your application online and predictable when unexpected traffic spikes arrive.
Start with an in-memory token bucket. Protect your structs with sync.Mutex. Return clean HTTP 429 responses with Retry-After headers.
Once your traffic outgrows a single machine, move your state to Redis. Your future self will thank you during the next unexpected traffic wave.