Skip to content
IR
01system design

Building a Rate Limiter Library

Four algorithms, one choice, and about sixty lines of Go that protect a service from its busiest clients without slowing down everyone else.

Published (15 January 2026)13 min readGoAlgorithms

The problem

One client retries in a tight loop, a batch job forgets to sleep, a scraper finds your search endpoint. Without a limit, a single caller can use up the capacity meant for everyone. A rate limiter answers one question on every request: may this caller proceed right now? It has to answer in microseconds, under heavy concurrency, and it must never become the bottleneck it was meant to prevent.

This post builds an in-process limiter: one service, one memory space. Part 11 moves the same idea into a shared store so a fleet of servers can agree on a single budget.

Requirements

Functional

  • Limit by key: user, API key or IP.
  • A steady rate plus a short burst.
  • Tell rejected callers when to retry.

Non-functional

  • O(1) time and memory per key.
  • Safe for concurrent use.
  • Deterministic in tests.

Choosing an algorithm

Four designs come up again and again. They differ in how much they remember about each caller and in what they do at the edge of a window.

AlgorithmMemory per keyBurstsAccuracy
Fixed windowO(1)Up to 2× at window edgesLow
Sliding logO(n)None allowedExact
Sliding window counterO(1)SmoothedApproximate
Token bucketO(1)ConfigurableExact for rate
Table 1 · n is the number of requests in the window. The highlighted row is the one we build.

The token bucket wins for a library: two numbers per key, a burst you can tune separately from the rate, and no timers running in the background.

How a token bucket works

Token bucket diagramPNG or SVG · 1600 × 900 · light background
Figure 1 · Refill at r tokens per second into a bucket that holds at most b. Each request takes one token: allowed, or 429 when empty. The rate sets the long-run average; the capacity sets how big a burst can be.

There is no background refill loop. Each time a request arrives we work out how many tokens would have dripped in since the last request, add them, cap at the capacity, and then try to spend one. Time does the bookkeeping for us.

The core

The bucket stores a token count and the time it was last updated. The Clock interface is the only concession to testing, and it costs nothing at runtime.

ratelimit/bucket.go
package ratelimit
import (	"math"	"sync"	"time")
// Clock exists so tests can move time forward without sleeping.type Clock interface{ Now() time.Time }
type Bucket struct {	mu       sync.Mutex	clock    Clock	tokens   float64	capacity float64	rate     float64 // tokens per second	last     time.Time}
func (b *Bucket) Allow() bool {	b.mu.Lock()	defer b.mu.Unlock()
	now := b.clock.Now()	elapsed := now.Sub(b.last).Seconds()	b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.rate)	b.last = now
	if b.tokens < 1 {		return false	}	b.tokens--	return true}
Listing 1 · The highlighted line is the whole refill: elapsed time × rate, capped at capacity.

Tokens are a float64 on purpose. At 2 requests per second, a request 300 ms after the last one earns 0.6 of a token; rounding that away would make the limiter stricter than configured.

One bucket per client

A limiter keeps a map from key to bucket. The map lock is held only long enough to find or create the bucket; the bucket's own lock does the rest, so two different clients never wait on each other.

ratelimit/limiter.go
type Limiter struct {	mu       sync.Mutex	buckets  map[string]*Bucket	rate     float64	capacity float64	clock    Clock}
func (l *Limiter) Allow(key string) bool {	l.mu.Lock()	b, ok := l.buckets[key]	if !ok {		b = &Bucket{clock: l.clock, tokens: l.capacity, capacity: l.capacity, rate: l.rate, last: l.clock.Now()}		l.buckets[key] = b	}	l.mu.Unlock()
	return b.Allow()}
func (l *Limiter) Middleware(next http.Handler) http.Handler {	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		if !l.Allow(clientKey(r)) {			w.Header().Set("Retry-After", "1")			http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)			return		}		next.ServeHTTP(w, r)	})}
Listing 2 · Per-key buckets and an HTTP middleware that answers 429 with a Retry-After header.

Testing with a fake clock

Tests that call time.Sleep are slow and flaky. With an injected clock, the test moves time forward itself and every assertion is exact.

ratelimit/bucket_test.go
type fakeClock struct{ now time.Time }
func (c *fakeClock) Now() time.Time      { return c.now }func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
func TestBucketRefills(t *testing.T) {	clock := &fakeClock{now: time.Unix(0, 0)}	b := &Bucket{clock: clock, tokens: 2, capacity: 2, rate: 1, last: clock.Now()}
	if !b.Allow() || !b.Allow() {		t.Fatal("a fresh bucket should allow a full burst")	}	if b.Allow() {		t.Fatal("an exhausted bucket should reject")	}
	clock.Advance(time.Second)	if !b.Allow() {		t.Fatal("one second should refill exactly one token")	}}
Listing 3 · Burst, exhaust, refill: the three behaviours that matter, tested without sleeping.

The refill shown in Listing 1 is what makes this testable: no goroutine, no ticker, nothing to wait for.

Trade-offs

Good fit

  • A single service or sidecar.
  • Protecting a downstream dependency.
  • Smoothing bursts from background jobs.

Not a fit

  • Quotas shared across many replicas.
  • Limits that must survive a restart.
  • Billing-grade, per-request accounting.

Every one of the "not a fit" cases comes down to state that has to live outside the process. That is where the series goes next.