Rate Limiting in Go: Fixed Window vs Token Bucket

Everyone grabs a rate limiting library and calls it a day. But what if building your own in Go reveals why they fail under real load? Here's the gritty truth on fixed window vs. token bucket, Redis atomicity, and when to skip the crates.

Ditching Rate Limiting Libraries: Building Fixed Window and Token Bucket in Go from Scratch — theAIcatchup

Key Takeaways

  • Build custom rate limiters in Go to grok concurrency races that libraries hide.
  • Token bucket beats fixed window for bursty real-world traffic; use Redis Lua for atomic ops.
  • Real Redis in CI catches Lua bugs mocks miss—scale-ready from day one.

Everyone figured rate limiting was a solved problem—grab a library like golang.org/x/time/rate or some Redis middleware, tweak a few knobs, and you’re golden. Libraries handle the concurrency nightmares, right? Wrong. This Go implementation tears the hood off, showing exactly why fixed window flops on boundaries and token bucket shines for bursty traffic. It changes everything if you’re scaling APIs without trusting abstractions.

Look, I’ve seen too many Silicon Valley unicorns melt under DDoS because their ‘enterprise’ rate limiter choked on races. Building from scratch? It’s not masochism—it’s insurance.

Why Bother Building Rate Limiting in Go Yourself?

Libraries are comfy. But they hide the ugly bits—like two requests slamming your counter at the exact same millisecond, both slipping through. The original post nails it: “Rate limiting looks simple until you think about concurrent requests hitting the same counter at the same millisecond.”

Rate limiting looks simple until you think about concurrent requests hitting the same counter at the same millisecond. That’s where the interesting problems live — and a library abstracts all of that away from you.

And here’s my hot take, one you won’t find in the code: this is straight out of 2010 Twitter playbook. Back then, before guava or Redis libs matured, they hacked rate limits in Scala with memcached. Result? Fail whales everywhere during bursts. Today, with Go’s speed and Redis Lua, you’re rebuilding that wisdom minus the outages. Bold prediction: as AI spits out more custom code, we’ll see fewer library lock-ins and more hybrid setups where you own the core logic.

But. Simplicity first. Fixed window.

It’s dead simple: N requests per window, reset on tick. Here’s the core:

func (fw *FixedWindow) Allow(key string) (bool, error) { count, err := fw.store.Increment(key, fw.windowSize) if err != nil { return false, err } return count <= fw.limit, nil }

Fast. One op. But oh, the boundary attack. Window flips at midnight—bam, 100 reqs at 11:59, 100 at 12:00. You’ve doubled your limit in seconds. Naive as hell for public APIs.

Token bucket? Smarter.

Bucket maxes at capacity, refills steadily. Idle user? Bursts allowed up to the cap. Code’s a tad hairier:

func (tb *TokenBucket) Allow(key string) (bool, error) { now := time.Now().Unix() tokens, err := tb.store.GetTokens(key, tb.refillRate, tb.capacity, now) if err != nil { return false, err } return tokens > 0, nil }

Tracks tokens and last refill. Computes delta every time. Bursts handled—perfect for human users hammering your endpoint after coffee.

The Redis Lua Atomicity Hack That Saves Your Ass

Race conditions. The silent killer.

Two reqs: both read tokens=5, both calc refill to 6, both write 5 (decremented). Now you’ve allowed 2 extra.

Lua to the rescue. Single atomic script on Redis server—no locks, one roundtrip.

local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refill_rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local bucket = redis.call(‘HMGET’, key, ‘tokens’, ‘last_refill’) local tokens = tonumber(bucket[1]) or capacity local last_refill = tonumber(bucket[2]) or now local elapsed = now - last_refill local new_tokens = math.min(capacity, tokens + elapsed * refill_rate) if new_tokens < 1 then return 0 end redis.call(‘HMSET’, key, ‘tokens’, new_tokens - 1, ‘last_refill’, now) return 1

Beautiful. Fixed window’s even cleaner—INCR wrapped with EXPIRE on first hit.

This isn’t hype. It’s engineering porn for anyone who’s debugged a leaky limiter at 3 AM.

And the CI? GitHub Actions spins real Redis. No mocks fooling you.

name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: redis: image: redis ports: - 6379:6379 steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ‘1.21’ - run: go test ./…

Mocks miss Lua bugs. Real Redis catches ‘em.

Fixed Window vs Token Bucket: Which for Your Go Backend?

Fixed Window Token Bucket
Simple More complex
Poor bursts Good bursts
Boundary vuln No
1 Redis op 1 (Lua)
Internal APIs Public APIs

Token bucket wins for user-facing. Fixed for service mesh.

But who’s cashing in? Redis Corp loves this—more Lua, more clusters. Go maintainers? Free labor. You? Bulletproof scaling without some $10k/month service.

Skeptical vet insight: I’ve covered API gateways from Mashery to Kong. They all started custom like this, then bloated. Stick to basics, or you’ll pay.

Is Custom Rate Limiting in Go Worth the Hassle for Scale?

Short answer: yes, if traffic’s unpredictable. Libraries like uber-go/ratelimit are great starters, but peek inside—they’re often token bucket wrappers with less control.

Scale hits. Say 10k RPS. Fixed window? Tune windows tiny, lose bursts. Token? Tune refill/capacity, smooth sailing.

Tradeoff: token’s two-field state (tokens, timestamp) vs fixed’s single counter. Redis HMGET/HMSET fine at scale—Lua keeps it atomic.

Real-world? Stripe-ish APIs need token for payments bursts. Internal microservices? Fixed suffices.

Don’t sleep on expiry. Lua sets TTL smartly—no zombie keys.

Why Does Redis Lua Matter More Than You Think?

Go’s goroutines scream concurrency. Redis single-threaded? Lua runs uninterruptible.

Alternative: distributed locks (Redlock). Sloooow. Or transactions (MULTI/EXEC). Still races possible.

Lua’s the minimalist win. Zero extra deps.

Historical parallel: NGINX’s limit_req module used fixed windows early on. Upgrades to leaky buckets fixed their outage porn. Same lesson here.

When Will Rate Limiting Libraries Die Out?

Never fully. But with Go 1.21+, Redis 7+, custom beats config hell.

Prediction: Wasm edges will embed these in proxies. No more central chokepoints.

Test it. Fork the repo, spike load with Vegeta. Watch boundaries burst fixed windows.


🧬 Related Insights

Frequently Asked Questions

What is token bucket rate limiting in Go?

It’s a bucket that refills tokens over time; consume one per request. Handles bursts better than fixed windows, implemented atomically with Redis Lua.

Fixed window vs token bucket: which for APIs?

Token bucket for public APIs (burst-friendly). Fixed window for simple internal calls (dead simple, but watch boundaries).

How to implement rate limiting with Redis in Go?

Use Lua scripts for atomicity: read state, compute refill, decrement, write—all in one server-side op. Skip naive GET/SET.

Elena Vasquez
Written by

Senior editor and generalist covering the biggest stories with a sharp, skeptical eye.

Frequently asked questions

What is token bucket rate limiting in Go?
It's a bucket that refills tokens over time; consume one per request. Handles bursts better than fixed windows, implemented atomically with Redis Lua.
Fixed window vs token bucket: which for APIs?
Token bucket for public APIs (burst-friendly). Fixed window for simple internal calls (dead simple, but watch boundaries).
How to implement rate limiting with Redis in Go?
Use Lua scripts for atomicity: read state, compute refill, decrement, write—all in one server-side op. Skip naive GET/SET.

Worth sharing?

Get the best AI stories of the week in your inbox — no noise, no spam.

Originally reported by dev.to

Stay in the loop

The week's most important stories from theAIcatchup, delivered once a week.