← All weekly notes

13 min read

Learning rate limiter algorithms in Go

Implementing fixed window, sliding window log, sliding window counter, token bucket and leaky bucket to understand their behavior and trade-offs.

Go · Algorithms · Rate limiting · Concurrency

I wanted to understand rate limiters as algorithms rather than only as infrastructure components. The simplest way was to implement each one and pass explicit timestamps into it. That makes window boundaries, refill behavior and burst handling visible without waiting for a real clock.

This note builds five in-memory limiters:

  1. Fixed window.
  2. Sliding window log.
  3. Sliding window counter.
  4. Token bucket.
  5. Leaky bucket.

The examples use seconds as the time unit and keep separate state for every user. They are small enough to study, but they also handle concurrent calls with a mutex.

One interface for every algorithm

All five implementations satisfy the same interface:

package main import (	"fmt"	"sync") type RateLimiter interface {	Allow(userID string, timestamp int64) bool}

Passing the timestamp from the caller is deliberate. It makes the algorithms deterministic and easy to test:

Allow("u1", 8)  → evaluate a request from u1 at second 8

In an application I would normally inject a clock or pass time.Time. All timestamps given to one limiter must use the same unit and clock source.

1. Fixed window

A fixed-window limiter splits time into equal intervals and keeps one counter per user for the current interval.

For a ten-second window:

[0, 10)  [10, 20)  [20, 30)

The aligned start of a window is:

windowStart := (timestamp / windowSize) * windowSize

At timestamp 17 with a window size of 10, the result is 10. At timestamp 29 it is 20.

Implementation

type FixedWindowState struct {	count       int	windowStart int64} type FixedWindowLimiter struct {	mu         sync.Mutex	limit      int	windowSize int64	users      map[string]*FixedWindowState} func NewFixedWindowLimiter(limit int, windowSize int64) RateLimiter {	if limit < 1 || windowSize < 1 {		panic("limit and window size must be positive")	} 	return &FixedWindowLimiter{		limit:      limit,		windowSize: windowSize,		users:      make(map[string]*FixedWindowState),	}} func (f *FixedWindowLimiter) Allow(userID string, timestamp int64) bool {	f.mu.Lock()	defer f.mu.Unlock() 	windowStart := (timestamp / f.windowSize) * f.windowSize	state, exists := f.users[userID] 	if !exists {		f.users[userID] = &FixedWindowState{			count:       1,			windowStart: windowStart,		}		return true	} 	// Reject time moving backwards in this deterministic example.	if windowStart < state.windowStart {		return false	} 	if windowStart > state.windowStart {		state.windowStart = windowStart		state.count = 1		return true	} 	if state.count >= f.limit {		return false	} 	state.count++	return true}

With a limit of three requests per ten seconds:

t=1   allow, count=1 in [0,10)t=2   allow, count=2 in [0,10)t=3   allow, count=3 in [0,10)t=4   rejectt=11  allow, new window [10,20)

State is constant per active user: one window start and one count.

Boundary burst

The weakness appears at a window boundary. A user can send three requests just before second 10 and three more just after it:

7, 8, 9       → three accepted10, 11, 12    → three accepted

Six requests arrive in roughly five seconds even though the configured policy sounds like three requests per ten seconds. The algorithm obeys its fixed windows, but a fixed window is not the same as every possible rolling ten-second interval.

Use fixed window when small boundary bursts are acceptable and low state cost matters more than rolling accuracy.

2. Sliding window log

A sliding-window log stores the timestamp of every accepted request. For a request at time now, it removes timestamps older than the rolling interval:

[now - windowSize, now]

It then compares the number of remaining timestamps with the limit.

Implementation

type SlidingLogState struct {	timestamps []int64} type SlidingWindowLog struct {	mu         sync.Mutex	limit      int	windowSize int64	users      map[string]*SlidingLogState} func NewSlidingWindowLog(limit int, windowSize int64) RateLimiter {	if limit < 1 || windowSize < 1 {		panic("limit and window size must be positive")	} 	return &SlidingWindowLog{		limit:      limit,		windowSize: windowSize,		users:      make(map[string]*SlidingLogState),	}} func (sl *SlidingWindowLog) Allow(userID string, timestamp int64) bool {	sl.mu.Lock()	defer sl.mu.Unlock() 	state, exists := sl.users[userID]	if !exists {		sl.users[userID] = &SlidingLogState{			timestamps: []int64{timestamp},		}		return true	} 	if len(state.timestamps) > 0 &&		timestamp < state.timestamps[len(state.timestamps)-1] {		return false	} 	cutoff := timestamp - sl.windowSize	firstKept := 0	for firstKept < len(state.timestamps) &&		state.timestamps[firstKept] < cutoff {		firstKept++	} 	state.timestamps = state.timestamps[firstKept:]	if len(state.timestamps) >= sl.limit {		return false	} 	state.timestamps = append(state.timestamps, timestamp)	return true}

The timestamps remain sorted because this example rejects time moving backwards. Instead of allocating a new kept slice for every request, it advances an index and reuses the existing backing array.

The boundary rule is explicit: a timestamp exactly equal to now - windowSize is still retained. Changing < cutoff to <= cutoff would make the interval open on the left instead.

Walking through the original sequence

For a limit of three and a window size of ten:

t=1   [1]        → allowt=3   [1,3]      → allowt=5   [1,3,5]    → allowt=8   [1,3,5]    → rejectt=12  [3,5,12]   → allow; 1 is older than the cutoff 2t=13  [3,5,12]   → reject; 3 is exactly on the cutoff

This is much more accurate than a fixed window because every request evaluates the interval relative to its own timestamp.

The trade-off is memory and update work. The limiter stores up to the accepted request limit per active user. It also has to remove expired timestamps. A deque or ring buffer avoids retaining a large backing array for a user whose old entries have been discarded.

Use a sliding log when exact rolling-window behavior matters and the request limit is small enough for timestamp storage to be reasonable.

3. Sliding window counter

The sliding-window counter approximates a rolling window without storing every timestamp. It keeps two fixed-window counters:

previous window countcurrent window count

The previous count is weighted by how much of that window overlaps the current rolling interval.

At second 13 with ten-second windows:

previous fixed window: [0,10)current fixed window:  [10,20)rolling interval:      [3,13] elapsed in current window = 3 secondsprevious overlap weight = (10 - 3) / 10 = 0.7

If the previous window contains three requests and the current one contains one, the estimate is:

3 × 0.7 + 1 = 3.1 requests

Implementation

type SlidingCounterState struct {	previousWindowStart int64	previousCount       int	currentWindowStart  int64	currentCount        int} type SlidingWindowCounter struct {	mu         sync.Mutex	limit      int	windowSize int64	users      map[string]*SlidingCounterState} func NewSlidingWindowCounter(limit int, windowSize int64) RateLimiter {	if limit < 1 || windowSize < 1 {		panic("limit and window size must be positive")	} 	return &SlidingWindowCounter{		limit:      limit,		windowSize: windowSize,		users:      make(map[string]*SlidingCounterState),	}} func (sc *SlidingWindowCounter) Allow(	userID string,	timestamp int64,) bool {	sc.mu.Lock()	defer sc.mu.Unlock() 	windowStart := (timestamp / sc.windowSize) * sc.windowSize	state, exists := sc.users[userID]	if !exists {		state = &SlidingCounterState{			currentWindowStart: windowStart,		}		sc.users[userID] = state	} 	if windowStart < state.currentWindowStart {		return false	} 	if windowStart > state.currentWindowStart {		if windowStart == state.currentWindowStart+sc.windowSize {			state.previousWindowStart = state.currentWindowStart			state.previousCount = state.currentCount		} else {			// At least one complete inactive window was skipped.			state.previousWindowStart = windowStart - sc.windowSize			state.previousCount = 0		} 		state.currentWindowStart = windowStart		state.currentCount = 0	} 	elapsed := timestamp - state.currentWindowStart	previousWeight := float64(sc.windowSize-elapsed) /		float64(sc.windowSize)	estimatedCount := float64(state.previousCount)*previousWeight +		float64(state.currentCount) 	if estimatedCount >= float64(sc.limit) {		return false	} 	state.currentCount++	return true}

This fixes the incomplete parts of my first attempt: the receiver is now SlidingWindowCounter, field names are consistent, the window is rotated before calculating the estimate and Allow returns a boolean on every path.

Using the same sequence as the sliding log:

t=1   estimate 0.0 → allowt=3   estimate 1.0 → allowt=5   estimate 2.0 → allowt=8   estimate 3.0 → rejectt=12  estimate 2.4 → allowt=13  estimate 3.1 → reject

The calculation assumes requests in the previous window were distributed uniformly. If all three arrived at the end of that window, the estimate can undercount. If they all arrived at the beginning, it can overcount.

State remains constant per user, making this a practical middle ground between fixed-window simplicity and sliding-log accuracy.

Use a sliding counter when rolling behavior matters but exact timestamp storage is too expensive.

4. Token bucket

A token bucket represents permission as tokens:

capacity    = maximum stored tokensrefill rate = tokens added per secondrequest     = consumes one token

Tokens accumulate while a user is idle, up to the capacity. This saved credit permits a controlled burst. After the burst, requests are admitted only as new tokens arrive.

For a capacity of three and a refill rate of 0.2 tokens per second:

the bucket starts with 3 tokensone new token takes 5 secondsthe user can spend 3 tokens immediately

Implementation

type TokenBucketState struct {	tokens     float64	lastRefill int64} type TokenBucketLimiter struct {	mu         sync.Mutex	capacity   float64	refillRate float64	users      map[string]*TokenBucketState} func NewTokenBucketLimiter(capacity int, refillRate float64) RateLimiter {	if capacity < 1 || refillRate <= 0 {		panic("capacity and refill rate must be positive")	} 	return &TokenBucketLimiter{		capacity:   float64(capacity),		refillRate: refillRate,		users:      make(map[string]*TokenBucketState),	}} func (tb *TokenBucketLimiter) Allow(userID string, timestamp int64) bool {	tb.mu.Lock()	defer tb.mu.Unlock() 	state, exists := tb.users[userID]	if !exists {		state = &TokenBucketState{			tokens:     tb.capacity,			lastRefill: timestamp,		}		tb.users[userID] = state	} 	if timestamp < state.lastRefill {		return false	} 	elapsed := timestamp - state.lastRefill	state.tokens += float64(elapsed) * tb.refillRate	if state.tokens > tb.capacity {		state.tokens = tb.capacity	}	state.lastRefill = timestamp 	if state.tokens < 1 {		return false	} 	state.tokens--	return true}

The bucket refills lazily. It does not need a background goroutine or ticker. Every call calculates how many tokens accumulated since the last request.

Floating-point tokens make fractional rates easy to understand. A production implementation may store scaled integers or elapsed nanoseconds to control rounding behavior.

Example

capacity=3, refill=0.2 token/second t=0   allow, 2 tokens remaint=0   allow, 1 token remainst=0   allow, 0 tokens remaint=0   rejectt=5   allow, one token has refilledt=6   reject, only 0.2 token has refilled

Token bucket is a strong default for API traffic because it expresses both the long-term rate and the allowed burst explicitly.

5. Leaky bucket

The leaky-bucket model imagines requests as water entering a bucket. Water leaves at a constant rate. A request is rejected when adding it would overflow the bucket.

requests add water         ↓    ┌─────────┐    │ bucket  │ capacity controls burst tolerance    └────┬────┘         ↓    constant leak rate

There are two common forms:

  • As a meter: leaked capacity is calculated on each request. A request is rejected when the bucket is full.
  • As a queue: accepted work waits in the bucket and a worker releases it at a steady rate.

The implementation below is the meter form.

Implementation

type LeakyBucketState struct {	level    float64	lastLeak int64} type LeakyBucketLimiter struct {	mu       sync.Mutex	capacity float64	leakRate float64	users    map[string]*LeakyBucketState} func NewLeakyBucketLimiter(capacity int, leakRate float64) RateLimiter {	if capacity < 1 || leakRate <= 0 {		panic("capacity and leak rate must be positive")	} 	return &LeakyBucketLimiter{		capacity: float64(capacity),		leakRate: leakRate,		users:    make(map[string]*LeakyBucketState),	}} func (lb *LeakyBucketLimiter) Allow(userID string, timestamp int64) bool {	lb.mu.Lock()	defer lb.mu.Unlock() 	state, exists := lb.users[userID]	if !exists {		state = &LeakyBucketState{lastLeak: timestamp}		lb.users[userID] = state	} 	if timestamp < state.lastLeak {		return false	} 	elapsed := timestamp - state.lastLeak	state.level -= float64(elapsed) * lb.leakRate	if state.level < 0 {		state.level = 0	}	state.lastLeak = timestamp 	if state.level+1 > lb.capacity {		return false	} 	state.level++	return true}

With capacity three and a leak rate of 0.2 units per second, three simultaneous requests fit. A fourth is rejected. Five seconds later one complete unit has leaked, so one more request fits.

The meter form and token bucket are mathematical counterparts: one tracks available credit while the other tracks consumed capacity. The queue form is operationally different because it delays accepted work to create a steady output rate instead of immediately returning true.

Use leaky-bucket queueing when downstream traffic must be smoothed. Use the meter form when rejecting overflow is sufficient.

Running all five

The implementations above can be placed in one file in the same order, followed by this main function:

func main() {	var limiter RateLimiter 	limiter = NewFixedWindowLimiter(3, 10)	fmt.Println("fixed window")	fmt.Println(limiter.Allow("u1", 1))  // true	fmt.Println(limiter.Allow("u1", 2))  // true	fmt.Println(limiter.Allow("u1", 3))  // true	fmt.Println(limiter.Allow("u1", 4))  // false	fmt.Println(limiter.Allow("u1", 11)) // true	fmt.Println(limiter.Allow("u2", 4))  // true: independent user 	limiter = NewSlidingWindowLog(3, 10)	fmt.Println("\nsliding window log")	fmt.Println(limiter.Allow("u1", 1))  // true	fmt.Println(limiter.Allow("u1", 3))  // true	fmt.Println(limiter.Allow("u1", 5))  // true	fmt.Println(limiter.Allow("u1", 8))  // false	fmt.Println(limiter.Allow("u1", 12)) // true	fmt.Println(limiter.Allow("u1", 13)) // false 	limiter = NewSlidingWindowCounter(3, 10)	fmt.Println("\nsliding window counter")	fmt.Println(limiter.Allow("u1", 1))  // true	fmt.Println(limiter.Allow("u1", 3))  // true	fmt.Println(limiter.Allow("u1", 5))  // true	fmt.Println(limiter.Allow("u1", 8))  // false	fmt.Println(limiter.Allow("u1", 12)) // true	fmt.Println(limiter.Allow("u1", 13)) // false 	limiter = NewTokenBucketLimiter(3, 0.2)	fmt.Println("\ntoken bucket")	fmt.Println(limiter.Allow("u1", 0)) // true	fmt.Println(limiter.Allow("u1", 0)) // true	fmt.Println(limiter.Allow("u1", 0)) // true	fmt.Println(limiter.Allow("u1", 0)) // false	fmt.Println(limiter.Allow("u1", 5)) // true	fmt.Println(limiter.Allow("u1", 6)) // false 	limiter = NewLeakyBucketLimiter(3, 0.2)	fmt.Println("\nleaky bucket")	fmt.Println(limiter.Allow("u1", 0)) // true	fmt.Println(limiter.Allow("u1", 0)) // true	fmt.Println(limiter.Allow("u1", 0)) // true	fmt.Println(limiter.Allow("u1", 0)) // false	fmt.Println(limiter.Allow("u1", 5)) // true	fmt.Println(limiter.Allow("u1", 6)) // false}

Comparing the algorithms

AlgorithmState per userRolling accuracyBurst behaviorMain trade-off
Fixed windowConstantLow near boundariesCan burst across a boundarySimplest implementation
Sliding logUp to the accepted request limitExact for the chosen boundary ruleStrict rolling limitStores timestamps
Sliding counterConstantApproximateSmoother than fixed windowAssumes requests were evenly distributed
Token bucketConstantRate-basedExplicit burst capacityNeeds refill arithmetic
Leaky bucketConstant, plus queued jobs in queue formRate-basedRejects or delays overflowQueue form adds latency

There is no universally best limiter. The correct choice depends on the policy:

Simple quota per interval             → fixed windowExact rolling maximum                 → sliding window logCheap rolling approximation           → sliding window counterAverage rate with controlled bursts   → token bucketSmooth downstream processing          → leaky bucket queue

Concurrency and state ownership

Each implementation uses one mutex around its user map and state transitions. The lock is important because Allow is a read-modify-write operation. Two goroutines must not both observe the same count or token balance and admit requests independently.

One global mutex is intentionally simple. If profiling shows contention, the user map can be divided into shards with one lock per shard. Another option is to hash each user to a worker goroutine that exclusively owns its state. Those ownership patterns are explored in Five concurrency patterns from Go to distributed systems.

An atomic counter alone is not sufficient for these implementations. Updating a fixed-window limiter involves both windowStart and count. A token bucket must update both tokens and lastRefill as one state transition.

Moving from this code to production

These examples explain the algorithms, but a production limiter needs a few more decisions.

Choose the identity and policy key

The key may include a user, API key, endpoint or operation cost:

rate-limit:{tenant}:{endpoint}

Two endpoints can have different capacities. One expensive request may consume more than one token.

Use a reliable time source

Wall clocks can jump because of clock correction. Inside one process, elapsed-time calculations should use Go's monotonic clock information. In a distributed store, define which clock is authoritative and what happens when nodes disagree.

Clean up inactive users

The maps in this note retain every user forever. A real in-memory limiter needs expiry, periodic cleanup or a bounded cache. A Redis-backed limiter normally applies a TTL to inactive keys.

Make distributed updates atomic

Several application instances cannot enforce one global limit with independent in-memory maps. Shared state must be updated atomically. Reading a counter and writing it back as separate remote operations creates a race.

The separate note Designing a distributed rate limiter covers shared storage, atomic updates, failure behavior and policy design.

Return more than a boolean

Callers often need:

allowedremaining capacityretry-after durationlimit reset time

The boolean interface keeps the algorithm examples focused. A production result type should expose enough information to build correct HTTP headers and useful metrics.

What I want to remember

A rate limiter is state plus a time rule.

Fixed and sliding windows ask, “How many requests belong to this interval?” Token and leaky buckets ask, “How quickly does capacity recover?”

The algorithm defines the traffic shape. The mutex, shard owner or shared database only makes that state transition safe. Choosing the storage or synchronization mechanism does not replace choosing the policy.