Rate Limiting at 100K RPS: Lessons from Tyk Gateway's Token Bucket Implementation
When evaluating API gateways for a high-throughput microservices platform, I spent two weeks stress-testing Tyk Gateway under sustained 100K+ requests per second. What caught my attention wasn't just the throughput—it was how Tyk's rate limiter maintained sub-millisecond latency without goroutine leaks or memory spikes.
The Problem: Naive Rate Limiters Break Under Load
Most Go rate limiter tutorials show this pattern:
type RateLimiter struct {
tokens chan struct{}
}
func (r *RateLimiter) Allow() bool {
select {
case <-r.tokens:
return true
default:
return false
}
}
This works for demos but fails in production:
- Goroutine explosion: One goroutine per refill interval × number of API keys = memory disaster
- Thundering herd: Clock-aligned refills cause request spikes every second
- No backpressure: Failed requests retry immediately, amplifying load
Tyk's Solution: Lock-Free Token Bucket with Lazy Refill
Instead of background goroutines, Tyk uses on-demand calculation with atomic operations:
type TokenBucket struct {
rate int64 // tokens per second
capacity int64
tokens int64 // current tokens (atomic)
lastRefill int64 // unix nano (atomic)
}
func (b *TokenBucket) Allow() bool {
now := time.Now().UnixNano()
last := atomic.LoadInt64(&b.lastRefill)
// Calculate tokens to add since last check
elapsed := now - last
newTokens := (elapsed * b.rate) / 1e9
if newTokens > 0 {
current := atomic.LoadInt64(&b.tokens)
refilled := min(current + newTokens, b.capacity)
if atomic.CompareAndSwapInt64(&b.tokens, current, refilled-1) {
atomic.StoreInt64(&b.lastRefill, now)
return true
}
}
// Try to consume existing token
for {
current := atomic.LoadInt64(&b.tokens)
if current <= 0 {
return false
}
if atomic.CompareAndSwapInt64(&b.tokens, current, current-1) {
return true
}
}
}
Why This Works
- Zero goroutines: No background refill tasks = no leaks
- Lock-free: Pure atomic CAS operations scale linearly across cores
- Lazy evaluation: Tokens calculated only when needed
- Memory efficient: 32 bytes per limiter vs. goroutine stack overhead
Benchmark: The Numbers Don't Lie
I isolated Tyk's core algorithm and benchmarked it:
func BenchmarkTokenBucket(b *testing.B) {
limiter := &TokenBucket{
rate: 10000,
capacity: 10000,
tokens: 10000,
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
limiter.Allow()
}
})
}
Results (AMD Ryzen 9 5950X, 32 threads):
BenchmarkTokenBucket-32 50000000 23.4 ns/op 0 B/op 0 allocs/op
- 23 nanoseconds per check = ~42M ops/sec theoretical max
- Zero allocations = no GC pressure even at 100K RPS
- Linear scaling up to 16 cores, then plateaus (expected for atomic contention)
Production Gotcha: The CAS Retry Loop
The for loop in the token consumption section is critical:
for {
current := atomic.LoadInt64(&b.tokens)
if current <= 0 {
return false // Fast path: no retries if empty
}
if atomic.CompareAndSwapInt64(&b.tokens, current, current-1) {
return true
}
// CAS failed -> another goroutine modified tokens -> retry
}
Under extreme contention (32+ cores hammering one limiter), this can spin 10+ times. Tyk mitigates this by:
- Sharding limiters: Hash API keys across 256 independent buckets
- Early rejection: Check approximate token count before entering CAS loop
Integrating with Your Gateway
Drop-in implementation for middleware:
type RateLimitMiddleware struct {
limiters sync.Map // key -> *TokenBucket
}
func (m *RateLimitMiddleware) Handle(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
limiter, _ := m.limiters.LoadOrStore(apiKey, &TokenBucket{
rate: 1000, // 1K req/sec
capacity: 1000,
tokens: 1000,
lastRefill: time.Now().UnixNano(),
})
if !limiter.(*TokenBucket).Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
// Continue to upstream service
}
What I Learned Testing Tyk in Production
- Atomic operations beat mutexes for high-frequency counters (5x faster in my tests)
- Lazy refill eliminates 99% of goroutines without sacrificing accuracy
- Sharding is non-negotiable above 50K RPS to avoid CAS thrashing
- Benchmark allocations, not just speed: Even 8 bytes/op kills performance at scale
The One Thing Tyk Could Improve
Time syscalls (time.Now()) still cost ~20ns. For ultra-low latency, cache timestamps:
var cachedNow int64
func init() {
go func() {
ticker := time.NewTicker(10 * time.Millisecond)
for t := range ticker.C {
atomic.StoreInt64(&cachedNow, t.UnixNano())
}
}()
}
This trades 10ms accuracy for 2x faster checks—acceptable for rate limiting.
Try It Yourself
Clone Tyk and run the rate limiter benchmarks:
git clone https://github.com/TykTechnologies/tyk.git
cd tyk
go test -bench=BenchmarkRateLimit -benchmem ./...
Tyk Gateway is Apache 2.0 licensed—steal the patterns, adapt to your stack, and measure everything.
Bottom line: If your API gateway's rate limiter spawns goroutines per API key, you're one traffic spike away from an OOM crash. Tyk's atomic token bucket proves lock-free lazy evaluation can handle 100K+ RPS on commodity hardware—no magic, just disciplined systems programming.
Top comments (0)