Lesson 3 of 3 · 65 min

Build a Correct Fixed-Window Limiter

Build the first real limiter step by step, make concurrent requests safe, connect it to HTTP, and understand what must change when the service has multiple replicas.

Learning objectives

  • Explain fixed windows using a timeline.
  • Make one caller's counter safe when requests arrive at the same time.
  • Return correct allow, deny, remaining, and retry values.
  • Explain why local memory cannot enforce one shared limit across replicas.

The simplest useful algorithm

A fixed-window limiter divides time into equal boxes. Each caller gets a fresh counter in every box.

For example, imagine one box for every minute:

simple-model
12:00 minute  count requests in box A12:01 minute  forget box A and count in box B12:02 minute  forget box B and count in box C

This is called fixed window because the boxes start and end at fixed clock boundaries.

For this policy:

snippet.text
Limit:  10 requestsWindow: 60 seconds

the timeline looks like:

timeline
12:00:00.000  12:00:59.999                         window N12:01:00.000  12:01:59.999                         window N + 1

The window ID is integer division:

formula.go
windowID := now.UnixMilli() / policy.Window.Milliseconds()

Every timestamp in the same interval produces the same ID. Windows are aligned to the Unix epoch, so a one-minute policy resets at each UTC minute boundary—not one minute after a client's first request. Every caller resets at the same moment.

Production note

Epoch alignment has a surprising consequence for long windows: a one-day window resets at 00:00 UTC, not at the user's local midnight. If a product promises "daily" limits in the user's calendar day, that is a different design and must be built explicitly.

Do not worry about the formula yet

The division simply turns every timestamp in the same minute into the same window number. The tests will prove the boundaries for us.

Reset state lazily

We do not need a background job that clears every counter at midnight or every minute. Store two numbers:

  1. which time box this state belongs to;
  2. how many budget units have been used.
internal/ratelimit/fixedwindow/state.go
package fixedwindow// State is the constant-size algorithm payload for one key.type State struct {	WindowID int64	Count    int64}

At evaluation time:

snippet.text
stored WindowID == current WindowID  continue countingstored WindowID != current WindowID  start from zero

The payload is 16 bytes, but the complete cache entry is larger because keys, maps, interfaces, LRU links, and allocator metadata also consume memory.

Understand the boundary burst

A client can consume ten requests at 12:00:59.xxx and another ten at 12:01:00.xxx.

boundary
old window                     new window10 allowed      10 allowedat 59.9s        at 60.0s                         reset boundary

That is roughly 20 requests in a fraction of a second under a “10 per minute” policy.

The code did not make a mistake. The caller used one legal budget immediately before the boundary and another legal budget immediately after it.

This is the algorithm's main tradeoff:

  • constant-size state per active key;
  • expected O(1) work per request;
  • coarse enforcement at interval boundaries.

Algorithm honesty

Do not “fix” the boundary burst inside this implementation. That would silently create a different algorithm. Preserve it in a passing test and choose sliding-window or token-bucket semantics when the product cannot tolerate it.

Validate policy before serving traffic

Configuration errors should stop the application at startup, not surprise us after traffic arrives.

The policy checks two simple rules first:

policy-rules
Limit must be greater than zero.Window must be a valid whole number of milliseconds.

The package then checks one larger-number safety rule for future Redis compatibility:

internal/ratelimit/fixedwindow/policy.go
package fixedwindowimport (	"fmt"	"time"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit")const luaMaxExactInteger int64 = 1 << 53type Policy struct {	Limit  int64	Window time.Duration}func (p Policy) Validate() error {	if p.Limit <= 0 {		return fmt.Errorf("fixedwindow: limit must be > 0")	}	if err := ratelimit.ValidateWindow(p.Window); err != nil {		return fmt.Errorf("fixedwindow: %w", err)	}	windowMilliseconds := p.Window.Milliseconds()	if p.Limit > luaMaxExactInteger/windowMilliseconds {		return fmt.Errorf(			"fixedwindow: limit and window exceed exact integer budget",		)	}	return nil}

The final comparison uses division, not:

unsafe.go
p.Limit * p.Window.Milliseconds() > 1<<53

The multiplication could overflow before it is compared. Validation order also matters: ValidateWindow must reject a sub-millisecond value before .Milliseconds() can become zero and be used as a divisor.

First-pass takeaway

Validate configuration once when constructing the limiter. Request handling should never discover that the configured window is zero or unusable.

The storage trap: thread-safe is not atomic

Start with a deliberately simple local store:

internal/ratelimit/store/store.go
package storeimport (	"context"	"time")// Store operations are individually safe, but this interface cannot express// one atomic read-modify-write transaction.type Store[T any] interface {	Get(ctx context.Context, key string) (T, bool, error)	Set(ctx context.Context, key string, value T, ttl time.Duration) error	Delete(ctx context.Context, key string) error}

An in-memory cache may make Get safe and make Set safe. The complete sequence can still be wrong.

Imagine two cashiers reading the same gift-card balance:

gift-card-example
Starting balance: $10Cashier A reads $10 and approves a $7 purchase.Cashier B reads $10 and approves a $7 purchase.Both write the new balance as $3.$14 was spent, but the stored balance only changed by $7.

Our counter has the same problem:

Flow· Lost update with individually safe operations
goroutine A                      goroutine B
    │                                │
    ├── Get(key) → Count 9           │
    │                                ├── Get(key) → Count 9
    ├── decide → Count 10            ├── decide → Count 10
    ├── Set(key, 10)                 │
    │                                ├── Set(key, 10)
    ▼                                ▼

Two requests were admitted, but stored Count advanced only once.

The cache has no unsynchronized memory access, so Go's race detector may stay silent. This is a logical race across several synchronized operations.

Core distributed-systems lesson

Thread safety protects an operation. Atomicity protects an invariant across a state transition. Rate limiting needs the second guarantee.

In simpler language: protecting each step is not enough. The entire read, check, and update must behave like one indivisible action.

Adapt the local cache deliberately

The learning implementation uses process-cache to store counters in this application's memory:

terminal
go get github.com/tonmoytalukder/process-cache@v0.1.1go mod edit -go=1.24go mod tidy

The module minimum changes because process-cache v0.1.1 requires Go 1.24. A project cannot honestly advertise Go 1.22 compatibility while depending on a module that requires a newer language version.

Adapt the cache's untyped values to the generic store contract:

What an adapter does

An adapter is a small translator. process-cache stores values as any; our adapter checks that the value is really the State type the limiter expects.

internal/ratelimit/store/processcache.go
package storeimport (	"context"	"fmt"	"time"	processcache "github.com/tonmoytalukder/process-cache")type ProcessCache[T any] struct {	cache processcache.Cache}func NewProcessCache[T any](	cache processcache.Cache,) (*ProcessCache[T], error) {	if cache == nil {		return nil, fmt.Errorf("store: process cache must not be nil")	}	return &ProcessCache[T]{cache: cache}, nil}func (s *ProcessCache[T]) Get(	ctx context.Context,	key string,) (T, bool, error) {	var zero T	if err := ctx.Err(); err != nil {		return zero, false, err	}	value, found := s.cache.Get(key)	if !found {		return zero, false, nil	}	typed, ok := value.(T)	if !ok {		return zero, false, fmt.Errorf(			"store: key %q has unexpected type %T",			key,			value,		)	}	return typed, true, nil}func (s *ProcessCache[T]) Set(	ctx context.Context,	key string,	value T,	ttl time.Duration,) error {	if err := ctx.Err(); err != nil {		return err	}	if !s.cache.Set(key, value, ttl) {		return fmt.Errorf("store: process cache rejected key %q", key)	}	return nil}func (s *ProcessCache[T]) Delete(	ctx context.Context,	key string,) error {	if err := ctx.Err(); err != nil {		return err	}	s.cache.Delete(key)	return nil}var _ Store[int] = (*ProcessCache[int])(nil)

Get has three meaningful result shapes:

ResultMeaning
value, true, nilTyped state exists
zero, false, nilNormal cache miss
zero, false, non-nilState cannot be trusted

Test round-trip, expiry, delete, wrong dynamic type, cancelled context, rejected writes, and nil construction. Use the same fake clock for the cache and limiter so TTL tests remain deterministic.

Eviction changes correctness

process-cache is an LRU cache. Evicting a key deletes its counter and can give that identity a fresh budget. It is useful for this local learning lab, but an evictable cache is not automatically suitable for security-sensitive quotas.

Serialize one key's transition

For this local implementation, a lock makes the complete Get → decide → Set sequence behave like one action.

One global lock would make every caller wait in the same line. One lock per caller could use unlimited memory. A fixed set of lock shards gives us a practical middle ground.

internal/ratelimit/keyedlock/keyedlock.go
package keyedlockimport (	"fmt"	"sync")type Locker struct {	shards []sync.Mutex}func New(shardCount int) (*Locker, error) {	if shardCount <= 0 {		return nil, fmt.Errorf("keyedlock: shard count must be > 0")	}	return &Locker{shards: make([]sync.Mutex, shardCount)}, nil}func (l *Locker) Lock(key string) func() {	shard := &l.shards[shardIndex(key, len(l.shards))]	shard.Lock()	return shard.Unlock}func shardIndex(key string, shardCount int) int {	const (		offset64 = uint64(14695981039346656037)		prime64  = uint64(1099511628211)	)	hash := offset64	for i := 0; i < len(key); i++ {		hash ^= uint64(key[i])		hash *= prime64	}	return int(hash % uint64(shardCount))}

FNV-1a only selects a shard; it is not a security hash. A collision makes two unrelated keys wait on the same mutex, but their stored state remains separate.

Lock designMemoryConcurrencyImportant limitation
One global mutexConstantAll keys serializeHot key blocks everyone
Mutex per keyGrows with keysHighest local independenceAttacker-controlled memory
Fixed shardsBoundedUnrelated shards proceedCollisions serialize

These mutexes exist in one process. A second replica owns a different locker.

Give the locker its own small test file: prove that 100 goroutines incrementing a plain integer under the same key's lock always reach exactly 100, that two keys on different shards do not block each other, and that a non-positive shard count is rejected.

Simple picture

Each key chooses one of 256 checkout lines. Requests in the same line wait for each other. Requests in different lines may continue at the same time.

Assemble the limiter

The limiter now has four building blocks:

Building blockJob
PolicyHolds the limit and window
ClockSupplies the current time
StoreSaves the counter
Keyed lockPrevents two requests from corrupting one update

The limiter coordinates those pieces. A separate pure function performs the arithmetic.

internal/ratelimit/fixedwindow/limiter.go
package fixedwindowimport (	"context"	"fmt"	"time"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/keyedlock"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/store")const lockShards = 256type Limiter struct {	policy Policy	clock  ratelimit.Clock	store  store.Store[State]	locks  *keyedlock.Locker}func New(	policy Policy,	clock ratelimit.Clock,	stateStore store.Store[State],) (*Limiter, error) {	if err := policy.Validate(); err != nil {		return nil, err	}	if clock == nil {		return nil, fmt.Errorf("fixedwindow: clock must not be nil")	}	if stateStore == nil {		return nil, fmt.Errorf("fixedwindow: store must not be nil")	}	locks, err := keyedlock.New(lockShards)	if err != nil {		return nil, fmt.Errorf("fixedwindow: create keyed lock: %w", err)	}	return &Limiter{		policy: policy,		clock:  clock,		store:  stateStore,		locks:  locks,	}, nil}func (l *Limiter) Allow(	ctx context.Context,	key string,	cost int64,) (ratelimit.Decision, error) {	if err := ctx.Err(); err != nil {		return ratelimit.Decision{}, err	}	if cost <= 0 {		return ratelimit.Decision{}, fmt.Errorf(			"fixedwindow: cost must be > 0",		)	}	unlock := l.locks.Lock(key)	defer unlock()	state, found, err := l.store.Get(ctx, key)	if err != nil {		return ratelimit.Decision{}, fmt.Errorf(			"fixedwindow: load state: %w",			err,		)	}	now := l.clock.Now()	nextState, decision := decide(l.policy, state, now, cost)	if !found || nextState != state {		if err := l.store.Set(			ctx,			key,			nextState,			2*l.policy.Window,		); err != nil {			return ratelimit.Decision{}, fmt.Errorf(				"fixedwindow: save state: %w",				err,			)		}	}	return decision, nil}var _ ratelimit.Limiter = (*Limiter)(nil)

Read Allow as a transaction:

transaction
validate context and cost          lock the key's shard          load state          read the clock once          calculate the next state and decision          persist changed state          unlock and return

Invalid input is rejected before locking or touching state. The clock is read exactly once so a single decision cannot combine a window ID from one interval with retry metadata from the next.

If persistence fails, return an error—never an allowed decision. Otherwise the operation would proceed without consuming quota.

Keep decision logic pure

A pure function receives everything it needs as input. It does not read the real clock, call the cache, or change global data. The same inputs always produce the same outputs.

That makes the hardest arithmetic easy to test:

internal/ratelimit/fixedwindow/limiter.go
func decide(	policy Policy,	state State,	now time.Time,	cost int64,) (State, ratelimit.Decision) {	windowMilliseconds := policy.Window.Milliseconds()	windowID := now.UnixMilli() / windowMilliseconds	windowEnd := time.UnixMilli(		(windowID + 1) * windowMilliseconds,	)	if state.WindowID != windowID {		state = State{WindowID: windowID}	}	if cost > policy.Limit || state.Count > policy.Limit-cost {		return state, ratelimit.Decision{			Allowed:    false,			Limit:      policy.Limit,			Remaining:  remaining(policy.Limit, state.Count),			RetryAfter: windowEnd.Sub(now),			ResetAt:    windowEnd,			Reason:     "limit_exceeded",		}	}	state.Count += cost	return state, ratelimit.Decision{		Allowed:   true,		Limit:     policy.Limit,		Remaining: policy.Limit - state.Count,		ResetAt:   windowEnd,	}}func remaining(limit, count int64) int64 {	if count >= limit {		return 0	}	return limit - count}

Make arithmetic overflow-safe

This common check is unsafe:

unsafe.go
if state.Count + cost > policy.Limit {

The addition can wrap around math.MaxInt64. Instead:

safe.go
if cost > policy.Limit || state.Count > policy.Limit-cost {

Short-circuit evaluation guarantees policy.Limit-cost is computed only when cost is no larger than the limit.

First-pass takeaway

Integers have a maximum size. Adding before comparing can wrap a very large number into a wrong value. Rearranging the comparison avoids that addition.

An oversized positive cost is a valid request that cannot fit, so it receives a denied decision. Zero or negative cost is invalid input and receives an error.

TTL is cleanup, not reset

State is stored for 2 × Window to provide cleanup margin around a boundary. Correct reset behavior comes from comparing WindowID, not from assuming the cache expires at the perfect instant.

Do not refresh TTL for a denied request whose state did not change. Rewriting identical state adds work and can keep stale keys alive under constant rejected traffic.

Test the invariants

Deterministic time makes every test below fast and exact. You do not need to write every test at once. Use this order:

  1. make one allowed request;
  2. fill the budget;
  3. deny the next request;
  4. move time into the next window;
  5. add different keys;
  6. add concurrent requests;
  7. add invalid and extreme inputs.

Start the test file with the fake clock from Lesson 2 and one helper that owns the cache lifecycle. The fake clock lives here—not in the contracts package—because Go does not share test helpers across packages, and this is the first package that needs it:

internal/ratelimit/fixedwindow/limiter_test.go
package fixedwindowimport (	"sync"	"testing"	"time"	processcache "github.com/tonmoytalukder/process-cache"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/store")type fakeClock struct {	mu  sync.Mutex	now time.Time}func (c *fakeClock) Now() time.Time {	c.mu.Lock()	defer c.mu.Unlock()	return c.now}func (c *fakeClock) Advance(d time.Duration) {	c.mu.Lock()	defer c.mu.Unlock()	c.now = c.now.Add(d)}func newTestLimiter(	t testing.TB,	policy Policy,	start time.Time,) (*Limiter, *fakeClock) {	t.Helper()	clock := &fakeClock{now: start}	cache, err := processcache.NewMemoryCache(		processcache.WithCleanupDisabled(),		processcache.WithClock(clock),	)	if err != nil {		t.Fatal(err)	}	t.Cleanup(func() { _ = cache.Close() })	stateStore, err := store.NewProcessCache[State](cache)	if err != nil {		t.Fatal(err)	}	limiter, err := New(policy, clock, stateStore)	if err != nil {		t.Fatal(err)	}	return limiter, clock}

Two details in the helper are easy to miss. The same fake clock drives both the limiter and the cache, so TTL expiry and window arithmetic advance together. And every test starts at a realistic post-1970 timestamp: Go's integer division truncates negative numbers toward zero, so pre-1970 timestamps would produce surprising window IDs.

TestInvariant
Requests 1–10Admitted; remaining decreases to zero
Request 11Denied with nil error
Key isolationExhausting A does not consume B
Window resetFirst request in a new window is admitted
Retry metadataPoints to the aligned boundary
Boundary burstTwenty requests pass across adjacent windows
Invalid costReturns error and creates no state
Oversized costDenies without consuming state
Corrupted large countCannot overflow into an allowed result
Cancelled contextReturns error and creates no state

The retry-metadata test deserves concrete numbers. Start the clock at 12:00:30, exhaust a one-minute window, and deny one call. The correct assertions are:

retry-expectations
RetryAfter = 30s          (time left until the boundary)ResetAt    = 12:01:00     (the aligned boundary itself)

A common bug is computing the reset as now.Add(window)—that would claim 12:01:30, a full minute after rejection instead of thirty seconds. Retry metadata describes the algorithm's real boundary, not "one window from now."

Capture the known boundary behavior

The first behavioral test calls Allow with a context, so add "context" to the test file's import block:

internal/ratelimit/fixedwindow/limiter_test.go
func TestAllow_BoundaryBurst(t *testing.T) {	t.Parallel()	limiter, clock := newTestLimiter(		t,		Policy{Limit: 10, Window: time.Minute},		time.Date(2026, 7, 31, 12, 0, 59, 0, time.UTC),	)	allowed := 0	for i := 0; i < 10; i++ {		decision, err := limiter.Allow(context.Background(), "user-1", 1)		if err != nil {			t.Fatal(err)		}		if decision.Allowed {			allowed++		}	}	clock.Advance(time.Second)	for i := 0; i < 10; i++ {		decision, err := limiter.Allow(context.Background(), "user-1", 1)		if err != nil {			t.Fatal(err)		}		if decision.Allowed {			allowed++		}	}	if allowed != 20 {		t.Fatalf("allowed = %d, want 20", allowed)	}}

Prove behavior under concurrency

This test uses an atomic counter, so also add "sync/atomic" to the import block. It collects every goroutine error separately: the count is meaningful only when all 100 limiter calls succeed.

internal/ratelimit/fixedwindow/limiter_test.go
func TestAllow_Concurrent(t *testing.T) {	t.Parallel()	limiter, _ := newTestLimiter(		t,		Policy{Limit: 10, Window: time.Minute},		time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC),	)	var allowed atomic.Int64	errCh := make(chan error, 100)	var wg sync.WaitGroup	for i := 0; i < 100; i++ {		wg.Add(1)		go func() {			defer wg.Done()			decision, err := limiter.Allow(				context.Background(),				"user-1",				1,			)			if err != nil {				errCh <- err				return			}			if decision.Allowed {				allowed.Add(1)			}		}()	}	wg.Wait()	close(errCh)	for err := range errCh {		t.Errorf("Allow() error = %v", err)	}	if allowed.Load() != 10 {		t.Fatalf("allowed = %d, want exactly 10", allowed.Load())	}}

Run it repeatedly as well as under the memory race detector:

terminal
go test ./...go test -count=20 ./internal/ratelimit/fixedwindowgo test -race ./...

Optional experiment: break it on purpose

The fastest way to believe the storage-trap section is to see the failure yourself. Temporarily delete these two lines from Allow:

delete-temporarily.go
unlock := l.locks.Lock(key)defer unlock()

Then hammer the concurrency test:

terminal
go test -count=100 -run TestAllow_Concurrent \  ./internal/ratelimit/fixedwindow

You should see failures where far more than ten requests pass—one validated run ranged from 12 to 83 admitted requests. Now run the same broken code under -race: it may stay completely silent, because no goroutine touches unsynchronized memory. The behavioral test caught a logical bug the race detector cannot see. Restore the two lines and rerun the suite before continuing.

Why two concurrency checks?

-race looks for unsafe access to Go memory. The test assertion checks the business rule: exactly ten requests may pass. We need both.

Map decisions to HTTP

Keep /api/demo unrestricted. Add a separate /api/fixed-window route that uses the shared interface.

First, wire the real dependencies together at startup, inside main before the routes are registered. This is the moment Lesson 2's design pays off: the server sees only the ratelimit.Limiter interface, and misconfiguration fails at boot instead of during traffic:

cmd/server/main.go
cache, err := processcache.NewMemoryCache(	processcache.WithMaxSize(16*processcache.MB),	processcache.WithCleanupInterval(time.Minute),)if err != nil {	log.Fatal(err)}defer func() {	if err := cache.Close(); err != nil {		log.Printf("close cache: %v", err)	}}()stateStore, err := store.NewProcessCache[fixedwindow.State](cache)if err != nil {	log.Fatal(err)}limiter, err := fixedwindow.New(	fixedwindow.Policy{Limit: 10, Window: time.Minute},	ratelimit.SystemClock{},	stateStore,)if err != nil {	log.Fatal(err)}mux.HandleFunc("GET /api/fixed-window", fixedWindowHandler(limiter))

The import block of cmd/server/main.go grows accordingly:

cmd/server/main.go
import (	"fmt"	"log"	"math"	"net"	"net/http"	"strconv"	"time"	processcache "github.com/tonmoytalukder/process-cache"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/fixedwindow"	"github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/store")

Note this is the production path: SystemClock instead of the fake, and a bounded cache with periodic cleanup instead of the test cache.

The HTTP handler itself has only three paths:

handler-paths
limiter error   500 Internal Server Errorvalid denial    429 Too Many Requestsvalid approval  200 OK
cmd/server/main.go
func fixedWindowHandler(limiter ratelimit.Limiter) http.HandlerFunc {	return func(w http.ResponseWriter, r *http.Request) {		decision, err := limiter.Allow(			r.Context(),			clientKey(r),			1,		)		if err != nil {			http.Error(				w,				"limiter unavailable",				http.StatusInternalServerError,			)			return		}		w.Header().Set(			"X-RateLimit-Limit",			strconv.FormatInt(decision.Limit, 10),		)		w.Header().Set(			"X-RateLimit-Remaining",			strconv.FormatInt(decision.Remaining, 10),		)		if !decision.Allowed {			retrySeconds := max(				int64(1),				int64(math.Ceil(decision.RetryAfter.Seconds())),			)			w.Header().Set(				"Retry-After",				strconv.FormatInt(retrySeconds, 10),			)			http.Error(				w,				"rate limit exceeded",				http.StatusTooManyRequests,			)			return		}		w.Header().Set("Content-Type", "application/json")		_, _ = fmt.Fprint(			w,			`{"status":"ok","endpoint":"fixed-window"}`,		)	}}

X-RateLimit-* is a common legacy convention, not a claim of current standards compliance. The algorithm returns precise durations; the HTTP adapter rounds Retry-After upward.

For a demo identity, RemoteAddr is acceptable only with an explicit warning:

cmd/server/main.go
func clientKey(r *http.Request) string {	// Demo only. Production identity must use authenticated principals or an	// explicitly trusted proxy configuration.	host, _, err := net.SplitHostPort(r.RemoteAddr)	if err != nil {		return r.RemoteAddr	}	return host}

Manually verify both paths:

terminal
curl -i http://localhost:8080/api/demofor i in $(seq 1 15); do  curl -s -o /dev/null -w "%{http_code}\n" \    http://localhost:8080/api/fixed-windowdone

Within one window, the limited endpoint should return ten 200 responses followed by five 429 responses. The control endpoint should continue returning 200.

If your loop happens to straddle a real minute boundary, the split may differ—perhaps eight 200s, then a reset, then more. That is the algorithm behaving exactly as specified against the real clock, and it is precisely why the deterministic fake-clock tests, not manual curl runs, are the correctness evidence.

Measure the complete path

Benchmark one hot key and many keys:

benchmark-plan
BenchmarkAllow_SingleKey  lock contention + store pathBenchmarkAllow_ManyKeys   shard distribution + store path + key selection

Report ns/op, B/op, allocs/op, Go version, CPU, operating system, and the exact command:

terminal
go test -run '^$' -bench=. -benchmem -count=5 \  ./internal/ratelimit/fixedwindow

Do not assume many keys must be faster. A cache that maintains exact LRU order may synchronize internally and dominate both benchmarks.

Avoid optimizing a number without a profile:

terminal
go test -run '^$' \  -bench=BenchmarkAllow_SingleKey \  -benchmem \  -memprofile=mem.out \  ./internal/ratelimit/fixedwindowgo tool pprof -alloc_objects mem.out

From local correctness to distributed enforcement

So far, one application process owns the counter and the locks. A production service may run many copies of the application.

The keyed lock proves correctness inside one copy. It cannot reach into another machine or process:

Flow· Evolution of the state transition
single process
Get → keyed mutex → decide → Set
                  │
                  ▼
multiple replicas
atomic shared operation at the state owner
                  │
                  ▼
Redis / database / quota service
load + reset + compare + increment + expiry
must execute as one transaction

Simply moving Get and Set to Redis is not enough. Two replicas could repeat the same gift-card mistake. The shared state owner must perform load, check, update, and expiry as one atomic action.

Common choices include a Redis Lua script, a database transaction with the correct isolation, or a dedicated quota service.

Production design must also answer:

ConcernDecision to make
Backend timeoutHow long may admission wait?
Backend failureFail open, fail closed, or use a degraded local fallback?
Clock sourceApplication time or state-owner time?
Key cardinalityHow are unbounded identities expired and monitored?
Hot keysCan one tenant overload a single shard or Redis node?
Replica topologyIs the quota global, regional, or per service instance?
ObservabilityWhich metrics distinguish denial, failure, and latency?

Observe decisions, not identities

A useful starting metric set is:

metrics
rate_limit_decisions_total{algorithm, policy, outcome}rate_limit_errors_total{backend, reason}rate_limit_decision_duration_seconds{algorithm}rate_limit_state_operations_total{operation, outcome}

Do not place raw user IDs, API keys, or IP addresses in metric labels. High-cardinality or sensitive labels can damage both privacy and the monitoring system.

Failure policy is product policy

Fail-open preserves availability but can expose an expensive or sensitive dependency. Fail-closed preserves the quota but can turn a limiter outage into a service outage. The right choice may differ for public reads, login attempts, payments, and cost-bearing AI operations.

Choose fixed window deliberately

Use fixed window when…Choose another design when…
Quotas are coarse and boundary bursts are acceptableA brief 2× burst is unsafe
Constant state per key mattersExact moving-window fairness matters more
A simple product quota needs explainable behaviorSmooth traffic shaping is required
Local enforcement is an intentional first layerOne global budget must span replicas

Pure decision function

Why: Time and arithmetic boundaries can be tested without cache setup or goroutines.

Alternative: Mix clock reads, storage, and arithmetic in one method, making failures harder to isolate.

Fixed lock shards

Why: Lock memory remains bounded while unrelated shards can progress independently.

Alternative: One mutex per attacker-controlled key creates an unbounded second state store.

Deny oversized positive cost

Why: The input is well-formed but cannot fit inside the policy budget; no state should be consumed.

Alternative: Return an operational error and blur invalid evaluation with valid policy denial.

Final verification

Run one complete evidence gate:

terminal
find internal/ratelimit -name '*.go' -print0 | xargs -0 gofmt -wgofmt -w cmd/server/main.gogo mod tidygo test ./...go test -count=20 ./internal/ratelimit/fixedwindowgo test -race ./...go test -run '^$' -bench=. -benchmem \  ./internal/ratelimit/fixedwindowgo build ./...go vet ./...go mod verify

Course checkpoint

You have completed the course when you can defend these statements:

  • fixed windows are globally aligned and reset lazily;
  • boundary bursts are algorithm behavior, not a concurrency bug;
  • individually safe Get and Set calls do not make their sequence atomic;
  • behavioral concurrency tests catch lost updates that -race may not report;
  • the clock is read once per decision;
  • overflow-safe comparison uses subtraction after checking cost <= limit;
  • denial is a valid decision, while storage failure is an error;
  • Retry-After points to the aligned boundary and rounds up at the HTTP edge;
  • LRU eviction can reset quota and therefore affects correctness;
  • keyed locks coordinate only one process;
  • N independent replicas can admit approximately N times a local limit;
  • a distributed limiter needs one atomic state transition at the shared state owner.

The implementation is now honest and testable: production-minded because its boundaries are explicit, and intentionally incomplete because distributed coordination, identity trust, and failure policy deserve their own designs.

If you can explain the ticket-budget model, the fixed time boxes, the gift-card race, and why replicas need one atomic shared update, you understand the central ideas of this course.

© 2026 Tonmoy Talukder

Open to Academic / Research Opportunities · 2026
light theme, blue accent