Choose between Allow, Reserve, and Wait, size bursts deliberately, scope limiters per client, and test refill behaviour without slow sleeps.
· 8 min read

Rate Limiting Go Services with golang.org/x/time/rate


Allow, Reserve, and Wait share one token bucket, but they handle overload differently. Allow rejects immediately without consuming capacity. Reserve schedules capacity for later, while Wait blocks the calling goroutine until capacity is available or its context ends. On an inbound HTTP handler, that choice can be the difference between returning a 429 and keeping thousands of requests waiting in memory.

golang.org/x/time/rate is a token bucket, and the package docs describe the model precisely: a bucket of size b, initially full, refilled at r tokens per second. A successful Allow, Reserve, or Wait consumes or reserves one token. The N variants request n. Everything below follows from that model.

Creating a limiter: rate and burst are different knobs

lim := rate.NewLimiter(10, 20) // 10 events/sec sustained, up to 20 at once

The first argument is a rate.Limit, a float64 measured in events per second. The second is the burst: the most tokens a single call can consume, and the most the bucket ever holds.

If intervals are easier to reason about, rate.Every converts:

// One event every 250ms == 4 events/sec
lim := rate.NewLimiter(rate.Every(250*time.Millisecond), 1)

Two edge cases will bite you. A zero Limit allows nothing, which means the zero value rate.Limiter{} is valid but rejects every call. And a zero burst also allows nothing, unless the limit is rate.Inf. With Inf, burst is ignored and everything passes. That makes rate.NewLimiter(rate.Inf, 0) a tidy “limiter disabled” value you can pass around without nil checks.

Burst is your slack tolerance. A burst of 1 prevents callers from spending several tokens at the same instant. A burst equal to a whole-number per-second rate lets an idle client spend roughly one second of accumulated budget at once. There is no universal multiplier: size the burst from the concurrency your service can absorb or the burst policy of the upstream API. For a hard outbound quota, a small burst avoids spending the entire cold-start allowance immediately.

Allow, Reserve, Wait: pick based on what happens to the excess

Allow() returns a bool and nothing else. Use it when you intend to drop the request. Inbound HTTP is the obvious case, because a 429 beats queuing thousands of blocked goroutines.

func limitMiddleware(lim *rate.Limiter, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !lim.Allow() {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Wait(ctx) blocks until a token is available, the context is cancelled, or the expected wait exceeds the context’s deadline. That last clause is useful: WaitN returns an error immediately when it can determine that the tokens cannot arrive before the deadline. It does not wait for the deadline first. A request-scoped context therefore bounds the wait, but it does not make blocking a good overload policy for inbound traffic: every waiting request still occupies resources. The docs recommend Wait for most callers, and it fits outbound work where a background job or API client should slow down rather than fail.

func (c *Client) Fetch(ctx context.Context, id string) (*Record, error) {
	if err := c.lim.Wait(ctx); err != nil {
		return nil, fmt.Errorf("rate limit wait: %w", err)
	}
	return c.do(ctx, id)
}

One gotcha: WaitN errors if n exceeds the burst size, because no amount of waiting will ever satisfy it. If you consume a variable number of tokens (one per record in a batch, say), validate n <= lim.Burst() or clamp the batch.

If context deadlines are new territory, the context guide covers the propagation rules Wait depends on.

Reserve() sits between the two. It hands back a *Reservation telling you how long to wait, and the limiter accounts for that future token right away. What you gain over Wait is a look at the delay before you commit to it:

r := lim.Reserve()
if !r.OK() {
	// n exceeded burst; no delay will ever help
	return errors.New("request can never be satisfied by this limiter")
}

delay := r.Delay()
if delay > maxAcceptableDelay {
	r.Cancel() // release as much reserved capacity as possible
	return errTooBusy
}

select {
case <-time.After(delay):
	return doWork()
case <-ctx.Done():
	r.Cancel()
	return ctx.Err()
}

Cancel() is the part people forget. A reservation holds tokens whether you use them or not. Bail out without cancelling and you’ve burned capacity on something that never ran. CancelAt reverses the effect “as much as possible”: if other reservations landed in the meantime, the limiter can’t fully unwind it, but it does what it can.

If you already use Reserve to decide whether work can wait, Delay() can also inform a Retry-After value. Convert it to an HTTP date or round it up to whole seconds, and cancel the reservation when you reject the work. Do not create an extra reservation only to calculate the header: the reservation itself changes the limiter’s state.

Scoping limiters per client

A single package-level limiter limits your whole service, which is rarely what anyone wants. Per-API-key or per-IP limits mean a map of limiters plus eviction, because otherwise you leak one limiter per distinct key, forever.

type keyedLimiter struct {
	mu       sync.Mutex
	limiters map[string]*entry
	limit    rate.Limit
	burst    int
	ttl      time.Duration
}

type entry struct {
	lim  *rate.Limiter
	seen time.Time
}

func newKeyedLimiter(r rate.Limit, b int, ttl time.Duration) *keyedLimiter {
	return &keyedLimiter{
		limiters: make(map[string]*entry),
		limit:    r,
		burst:    b,
		ttl:      ttl,
	}
}

func (k *keyedLimiter) get(key string) *rate.Limiter {
	k.mu.Lock()
	defer k.mu.Unlock()

	e, ok := k.limiters[key]
	if !ok {
		e = &entry{lim: rate.NewLimiter(k.limit, k.burst)}
		k.limiters[key] = e
	}
	e.seen = time.Now()
	return e.lim
}

// reap removes limiters idle for longer than ttl. Run it from a ticker.
func (k *keyedLimiter) reap() {
	k.mu.Lock()
	defer k.mu.Unlock()

	cutoff := time.Now().Add(-k.ttl)
	for key, e := range k.limiters {
		if e.seen.Before(cutoff) {
			delete(k.limiters, key)
		}
	}
}

rate.Limiter is safe for concurrent use, so the mutex guards the map and nothing else. Release the lock before you call Allow.

For a finite, positive rate, set the TTL to at least burst / rate seconds: the time an empty bucket needs to refill completely. Evicting sooner can give a returning client a fresh full bucket before its old limiter would have recovered. Also bound or monitor the map’s cardinality if keys come from untrusted input; TTL eviction still allows roughly one entry per distinct key seen during the TTL window.

All of this covers a single process. Across N replicas, the aggregate ceiling can reach N times the per-process rate, and each replica retains its own burst allowance. Dividing the rate by a stable replica count gives a rough global limit when traffic is distributed evenly; it is not exact during uneven load or scaling events. Use a coordinated limiter when the global quota must be strict. x/time/rate is in-memory and does not coordinate between processes.

Adjusting limits at runtime

SetLimit and SetBurst reconfigure a live limiter, which is useful for reacting to backpressure. When a downstream service starts returning 429s, halve your outbound rate without rebuilding anything:

func (c *Client) backOff() {
	current := c.lim.Limit()
	if current > minRate {
		c.lim.SetLimit(current / 2)
	}
}

The docs flag the caveat: callers that already reserved but haven’t acted may violate or underuse the new limit. Changing the config doesn’t retroactively revoke granted reservations.

Tokens() returns available tokens as a float. Fine as a saturation gauge, though it’s a snapshot that’s stale the instant you read it.

Testing rate-limited code without sleeping

AllowN, ReserveN, TokensAt, and SetLimitAt all take an explicit time.Time. Feed them synthetic timestamps to test refill and reconfiguration logic instantly and deterministically. Wait uses the real clock, so test its deadline and cancellation behaviour separately.

func TestLimiterDrainsAndRefills(t *testing.T) {
	lim := rate.NewLimiter(10, 5) // 10/sec, burst 5
	base := time.Now()

	// Burst of 5 should all pass at t=0.
	for i := 0; i < 5; i++ {
		if !lim.AllowN(base, 1) {
			t.Fatalf("event %d should have been allowed", i)
		}
	}

	// Bucket is empty; the 6th is rejected.
	if lim.AllowN(base, 1) {
		t.Fatal("expected 6th event to be rejected")
	}

	// 10 tokens/sec means one token every 100ms.
	if !lim.AllowN(base.Add(100*time.Millisecond), 1) {
		t.Fatal("expected a token to have refilled after 100ms")
	}

	// After a full second the bucket is capped at burst, not 10.
	if got := lim.TokensAt(base.Add(time.Second)); got > 5 {
		t.Fatalf("tokens = %v, want <= burst of 5", got)
	}
}

That final assertion encodes the behaviour people get wrong: the bucket never accumulates past burst, however long it sits idle. An hour of silence does not buy you 36,000 tokens.

For Wait, test the cancellation path instead of the timing. Set a rate slow enough that no token can arrive in time, then check that the error comes back fast:

func TestWaitRespectsDeadline(t *testing.T) {
	lim := rate.NewLimiter(rate.Every(time.Hour), 1)
	lim.Allow() // drain the single token

	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	start := time.Now()
	if err := lim.Wait(ctx); err == nil {
		t.Fatal("expected an error when the deadline precedes the next token")
	}
	if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
		t.Fatalf("Wait blocked for %v; it should fail fast when the deadline can't be met", elapsed)
	}
}

Wait returns almost immediately because it compares the required delay against the deadline before it blocks.

rate.Sometimes for log spam

The package also ships rate.Sometimes, which isn’t a token bucket at all. It’s a small struct with First, Every, and Interval fields deciding when Do runs the function you give it:

var sometimes = rate.Sometimes{First: 3, Interval: 10 * time.Second}

func handleParseError(err error) {
	sometimes.Do(func() {
		log.Printf("parse failed: %v", err)
	})
}

The filters are a union, not an intersection. First: 3, Interval: 10s logs the first three occurrences, then at most once every ten seconds. The first call to Do always runs. A zero rate.Sometimes behaves like sync.Once.

Do blocks while f runs and serialises concurrent calls, so keep the function cheap. Call Do on the same value from inside f and you deadlock.

Where this fits in a real service

Inbound HTTP: Allow in middleware keyed by client identity, 429 with a Retry-After header. Outbound calls to a rate-limited API: wrap the client method in Wait with the caller’s context, so a cancelled request doesn’t leave a goroutine parked in the limiter. Pair that with real transport timeouts. A limiter queuing requests in front of a client that waits forever has just relocated the problem.

Reach for Reserve when the delay value is the thing you need: to reject work that would wait too long, to populate a header, or to hand the action to another goroutine. Cancel on every path where you decide not to act.

What the bucket can’t do is tell requests apart. It has no idea whether the token it just handed out went to a health check or a bulk export, and it will happily starve the former to serve the latter. If that distinction matters to you, you need separate limiters per class of work, sized independently. The library won’t decide that for you; it’s a config question, and it’s usually the one worth arguing about.