Lesson 1 of 3 · 35 min

Rate Limiting Basics and the Baseline

Learn what rate limiting means in plain language, then build a tiny Go endpoint that gives the rest of the course a trustworthy starting point.

Learning objectives

  • Explain rate limiting using a simple real-world example.
  • Choose who receives a request budget and which endpoint uses it.
  • Tell the difference between HTTP 429 and 503 and calculate Retry-After.
  • Build and verify an intentionally unrestricted Go endpoint.

Start with the real problem

Imagine an API that normally receives 200 requests per second. A new integration deploys a bug and begins sending 20,000 requests per second with the same API key.

The service now has two responsibilities:

  1. protect healthy customers from one noisy client;
  2. tell the noisy client exactly what happened and when it may try again.

A rate limiter is a traffic gate. Before a request enters the service, the gate asks:

the-core-question
Has this caller used too much of its request budget?

If the answer is no, the request continues. If the answer is yes, the request is rejected for a short time.

In simple words

A rate limiter is similar to a ticket counter. Each caller receives a limited number of tickets for a period of time. Every accepted request spends a ticket.

A complete design still needs a few precise choices: why we are limiting, who owns the budget, which work spends it, and what the caller sees after the budget is empty.

Course destination

By Lesson 3, you will have a tested, concurrency-safe fixed-window limiter in Go, a limited HTTP endpoint, and a clear explanation of why that local implementation is still not a distributed rate limiter.

What are you protecting?

Do not choose an algorithm yet. First decide what problem the limiter should solve.

GoalExampleWhat the policy protects
FairnessOne tenant generates 90% of trafficOther tenants' share of capacity
Cost controlImage generation or SMS has a per-call costA financial or compute budget
Abuse preventionCredential stuffing targets loginAccounts and security controls
Overload protectionA traffic spike saturates a dependencyService availability

The same request count can mean very different risk. Ten fast cache reads may be harmless while ten report exports hold database connections for minutes.

Rate and concurrency answer different questions

snippet.text
Rate limit         How often may work begin?Concurrency limit  How much work may be active now?

A rate limit is like allowing ten people through a door every minute. A concurrency limit is like allowing only five people inside the room at once.

The first controls arrival speed. The second controls active work. Production systems often use both.

Design habit

Write one sentence before choosing an algorithm: “This limiter protects ___ from ___ by limiting ___.” If the sentence is vague, the policy will be vague too.

Define the policy boundary

Every rate limit must answer two beginner-friendly questions:

  1. Who owns the budget?
  2. What action spends the budget?

Together they complete this sentence:

snippet.text
Allow N units per [identity] for [operation].

Choose a trustworthy identity

The identity is simply the caller that owns the budget. Common choices, from strongest to weakest, are:

  1. API key or access token;
  2. authenticated user ID;
  3. tenant or organization ID;
  4. client IP address.

An IP address is convenient but ambiguous. Hundreds of people may share one corporate NAT address, while an attacker may rotate through many addresses. On login, however, a trusted user ID may not exist yet. That endpoint might combine a normalized account identifier with a trusted client IP.

Proxy safety

Never trust X-Forwarded-For just because it exists. A client can forge it. Forwarded client identity is trustworthy only when the application knows which proxy appended the header and strips untrusted values at the edge.

Scope the budget to an operation

A caller alone is not enough. We also need to know which action is spending the budget:

snippet.text
tenant=acme + GET /catalogtenant=acme + POST /reports

If those routes share one global budget, cheap reads can consume quota intended for an expensive report. A readable internal key might begin as:

snippet.text
rl:fixed-window:v1:POST/reports:tenant=acme

For now, remember this smaller rule:

key-rule
limiter key = caller + action

Production systems later normalize route templates and hide sensitive identity values.

Flow· From request to quota key
HTTP request
    │
    ├── authenticate ───────► trusted user or tenant
    │
    ├── normalize route ────► POST /reports/:reportID
    │
    └── choose policy ──────► 10 operations / minute
                                │
                                ▼
              tenant:acme | POST /reports/:reportID

Design the HTTP response

A caller needs to know whether it used too much quota or the whole service is unavailable.

StatusMeaningTypical owner of the problem
429 Too Many RequestsThis caller exceeded an application policyThe caller or its retry strategy
503 Service UnavailableThe service cannot currently handle workThe service or a dependency

Typical 503 causes are an overloaded database, a failing dependency, maintenance, or deliberate load shedding. None of those are the caller's fault, which is exactly why the caller must be able to tell the two responses apart.

Both responses may contain Retry-After, but they communicate different operational facts. That distinction affects client behavior, dashboards, and incident triage.

Memory shortcut

429 means “you have sent too many requests.” 503 means “the service cannot handle requests right now.”

Calculate Retry-After safely

HTTP supports either a date or a non-negative integer number of seconds:

response
HTTP/1.1 429 Too Many RequestsRetry-After: 3

Suppose the limiter says 2.1 seconds remain. The HTTP layer must round up to 3 seconds. Rounding down sends the caller back before the window resets.

snippet.text
340 ms  Retry-After: 12.1 s   Retry-After: 3

The worst version of this mistake is Retry-After: 0. It tells the client to retry immediately, the retry is rejected again, and a well-behaved client turns into a hot retry loop.

Keep sub-second precision inside the algorithm. Only the transport layer should convert it to HTTP's whole-second format.

Describe the traffic shape

“Ten requests per second” sounds complete, but it is not. Can all ten arrive at the same instant, or must they be spread across the second? That pattern is called the traffic shape.

AlgorithmTraffic behaviorPrimary tradeoff
Fixed windowFull budget is available in each aligned intervalSimple, but bursts at boundaries
Sliding window logCounts exact request history in a moving intervalPrecise, but more memory
Token bucketRefills tokens over time up to a capacityExplicit sustained rate and burst size
Leaky bucketDrains queued work at a steady rateSmooth output, but adds queuing

This course starts with fixed window because its small state makes correctness problems easy to see. Lesson 3 will preserve its boundary burst as a documented algorithm property rather than hiding it.

Decide where state lives

Suppose the policy is 100 requests per minute and the service has three replicas.

Flow· Process-local enforcement
                   load balancer
                  /      |      \
                 ▼       ▼       ▼
             replica A replica B replica C
              count 100 count 100 count 100

            effective cluster admission ≈ 300/minute

Each process-local limiter is behaving correctly, but each owns an independent budget.

If “replica” is new vocabulary, think of it as another running copy of the same application.

State modelStrengthsCosts and limitations
Process-localFast, simple, no network callLost on restart; multiplied across replicas
Shared storeOne coordinated cluster budgetNetwork latency, atomicity, and a new dependency

We will build locally first so the algorithm and contract are observable without a networked state system. The shared Limiter boundary in Lesson 2 is designed so a distributed implementation can later replace it.

Build a trustworthy control endpoint

The first code is intentionally not rate limited. A control endpoint lets you prove that later 429 responses come from the limiter rather than routing, deployment, or networking.

What you are doing now

You are building a tiny server that always says “OK.” Lesson 3 will add a second endpoint that sometimes says “too many requests.” Keeping both makes the difference easy to see.

Create the repository:

terminal
mkdir go-rate-limitercd go-rate-limitergit init -b maingo mod init github.com/TonmoyTalukder/go-rate-limitergo mod edit -go=1.22mkdir -p cmd/servertouch cmd/server/main.go

Two of these lines deserve a closer look:

  • The module path (github.com/TonmoyTalukder/go-rate-limiter) is the project's permanent import identity. Keep this exact path while following the course because every later import uses it. If you publish the project under your own account, replace the module path and every project import together after the course.
  • The go 1.22 line declares the minimum Go version the project requires, not the version installed on your machine. Your local toolchain may be newer. Declaring the lowest version you actually need keeps the module usable by more people. Lesson 3 will raise this minimum when a dependency demands it—an honest, deliberate change rather than an accident.

Keep the initial structure small:

repository
go-rate-limiter/ cmd/    server/        main.go go.mod

Do not create algorithm, store, or middleware packages yet. A folder is an architectural promise; create it when a real responsibility exists.

This checkpoint contains only files created by the commands above. Add repository-hygiene files such as .gitignore, LICENSE, and README.md when you prepare the project for publishing; they are intentionally outside this implementation lesson.

Implement the server

Create cmd/server/main.go:

cmd/server/main.go
package mainimport (	"fmt"	"log"	"net/http"	"time")func main() {	mux := http.NewServeMux()	// This endpoint stays unrestricted as the course control case.	mux.HandleFunc("GET /api/demo", func(w http.ResponseWriter, _ *http.Request) {		w.Header().Set("Content-Type", "application/json")		w.WriteHeader(http.StatusOK)		_, _ = fmt.Fprint(w, `{"status":"ok","endpoint":"demo"}`)	})	server := &http.Server{		Addr:              ":8080",		Handler:           mux,		ReadHeaderTimeout: 5 * time.Second,	}	log.Println("listening on http://localhost:8080")	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {		log.Fatalf("server failed: %v", err)	}}

The method-aware pattern GET /api/demo requires Go 1.22 and makes the route contract visible. ReadHeaderTimeout bounds how long a slow client may occupy a connection while sending headers.

One ordering rule inside the handler matters: headers are set before WriteHeader. Once the status line is written, the headers may already be on the wire, and later Header().Set calls are silently ignored. Lesson 3 relies on this when it adds Retry-After to denied responses.

Verify the baseline

Run the engineering gate before manual testing:

terminal
gofmt -w cmd/server/main.gogo mod tidygo test ./...go build ./...go vet ./...go run ./cmd/server

In a second terminal:

terminal
curl -i http://localhost:8080/api/democurl -i -X POST http://localhost:8080/api/democurl -i http://localhost:8080/unknown

The expected contract is:

RequestExpected resultWhat it proves
GET /api/demo200 with JSON bodyHappy path works
POST /api/demo405 Method Not AllowedRoute is method-specific
GET /unknown404 Not FoundUnknown paths are rejected

Finally, prove the endpoint has no limiter:

terminal
for i in $(seq 1 100); do  curl -s -o /dev/null -w "%{http_code}\n" \    http://localhost:8080/api/demodone | sort | uniq -c

Expected:

terminal
100 200

Write the first decision record

Before continuing, record the choices that later lessons will change.

DecisionCurrent choiceWhy
RuntimeGo 1.22+Method-aware standard-library routing
Transportnet/httpNo framework is needed for the baseline
Control routeGET /api/demoDeterministic comparison surface
IdentityNot implementedBelongs at the transport boundary
AlgorithmNoneAbsence is intentional in Lesson 1
CoordinationNoneLocal and distributed designs come later

Knowledge check

A service has four replicas and each replica enforces 100 requests per minute in local memory. What approximate cluster-wide rate can the client receive? Explain why this is a coordination problem, not a broken counter.

Lesson 1 checkpoint

You are ready for Lesson 2 when you can explain all of these without rereading:

  • what fairness, cost, abuse, and overload policies protect;
  • why rate and concurrency limits are different controls;
  • why identity and operation both belong in a limiter key;
  • why 429 is not the same as 503;
  • why Retry-After rounds upward;
  • how burst capacity differs from sustained rate;
  • why a local limit is multiplied across replicas;
  • why /api/demo must remain unrestricted.

Next, you will design the common contracts that let local and distributed algorithms answer the same admission question without leaking HTTP or storage details into the algorithm.

© 2026 Tonmoy Talukder

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