Token Bucket Rate Limiter in Go
Build a token bucket rate limiter from scratch using only Go's standard library.
Introduction
30 Second Summary
Every time you send too many requests to an API and get told to slow down, that is rate limiting at work. It is one of the most fundamental system design patterns protecting production APIs around the world.
In this project, you will build a token bucket rate limiter from scratch in Go. You will wire it into an HTTP server as middleware that automatically rejects excess traffic with the right headers and status codes.
What You'll Build
You will fire off a burst of 15 requests at your API with curl, watch the first 10 return 200, and see the rest bounce back with 429 Too Many Requests and a Retry-After header telling the client exactly when to try again.
By the end of this project, you'll have:
- A rate-limited HTTP API with two endpoints that accepts burst traffic up to a configurable limit, then returns 429 Too Many Requests when requests exceed it.
- A token bucket algorithm you built from scratch, where you can tune the burst size and refill rate to control how traffic flows through your server.
- Passing Go tests that prove the algorithm works by draining tokens with burst requests and confirming they refill over time.
- Secret Mission: Upgrade to per-IP rate limiting so each client gets their own token bucket.
Are there any prerequisites?
This project assumes basic programming experience and familiarity with Go fundamentals like variables, functions, and structs. You do not need prior experience with rate limiting or HTTP servers.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Putting the goal in your own words helps it stick before the hands-on work begins.
Set Up Your Go Environment
Before you can build your rate limiter, you need Go installed and a project initialized. Go's standard library includes everything this project needs, from an HTTP server to concurrency tools.
In this step, you'll install Go, create your project directory, and verify the whole setup with a simple program.
In this step, get ready to:
- Install Go 1.26 on your machine.
- Create a project directory and initialize a Go module.
- Write and run a starter Go program.
Install Go on your machine
Go is the programming language you'll use for this entire project. Let's check if you already have the right version installed.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check if Go is already installed by running this command:
go version
Look at the output in your terminal.
✔️ I see version 1.26 or higher
Your output looks something like go version go1.26.6 darwin/arm64 or go version go1.26.6 windows/amd64. Go is installed and ready. Move on to the next substep.
ⓧ I see an older version
You have Go installed, but this project requires version 1.26 or higher. The Go installer replaces your existing version automatically.
- Open the Go downloads page in your browser.
- Download the installer for your operating system. Choose go1.26.6.darwin-arm64.pkg for macOS (Apple Silicon) or go1.26.6.windows-amd64.msi for Windows.
- Run the downloaded installer and follow the prompts.
- Close your current terminal and open a brand new one.
- Run go version again to confirm you now see go version go1.26 in the output.
On an Intel Mac?
If your Mac has an Intel processor, download go1.26.6.darwin-amd64.pkg instead. You can check your processor by clicking the Apple menu and selecting About This Mac.
ⓧ Command not found
Go is not installed on your machine yet. Let's install it now.
- Open the Go downloads page in your browser.
- Download the installer for your operating system. Choose go1.26.6.darwin-arm64.pkg for macOS (Apple Silicon) or go1.26.6.windows-amd64.msi for Windows.
- Run the downloaded installer and follow the prompts.
- Close your terminal and open a brand new one. Go adds itself to your PATH during installation, but the current terminal session does not pick it up.
- Run go version to confirm Go is installed. You should see output starting with go version go1.26.
On an Intel Mac?
If your Mac has an Intel processor, download go1.26.6.darwin-amd64.pkg instead. You can check your processor by clicking the Apple menu and selecting About This Mac.
Still not seeing the right version?
Make sure you downloaded the installer from go.dev/dl and ran it to completion.
On macOS, you must open a brand new Terminal window after installation. The old window still uses the previous PATH.
Help me troubleshoot my Go installation.
Create your project directory
Every Go project lives in its own directory with a go.mod file that declares the module name and Go version. Let's create yours.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ratelimiter
cd ratelimiter
- Initialize a Go module by running this command:
go mod init ratelimiter
You should see the message go: creating new go.mod: module ratelimiter.
- Confirm the file was created by running:
ls
You should see go.mod in the output.
What is go.mod?
The go.mod file is the heart of a Go module. It declares your module's name (ratelimiter) and the Go version it targets.
As your project grows and adds external packages, Go tracks them here automatically. For this project, you won't need any external dependencies.
Don't see go.mod?
Make sure you ran go mod init ratelimiter from inside the ratelimiter folder. Run pwd to check your current location. You should see a path ending in /ratelimiter.
Help me troubleshoot my Go module initialization.
Write and run your starter program
With your project initialized, let's create a simple Go program to verify the full toolchain works end to end.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the ratelimiter folder on your Desktop, then click Open.
- In the Explorer sidebar, click the New File icon next to RATELIMITER.
- Type main.go and press Enter.
- Paste the following code into main.go:
package main
import "fmt"
func main() {
// Print a message to confirm Go is working
fmt.Println("Hello, rate limiter!")
}
What does this code do?
- package main tells Go this file is the entry point of your program.
- import "fmt" brings in Go's formatting package for printing text to the terminal.
- func main() is the function Go runs when you start the program.
- Save main.go by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Before you run this, what do you think you'll see in the terminal?
- Switch back to your terminal and run your program:
go run .
You should see Hello, rate limiter! printed in the terminal. Your Go toolchain is working end to end.
Seeing an error instead?
- Make sure you saved main.go before running go run .. Unsaved changes won't be compiled.
- Check that your terminal is inside the ratelimiter folder. Run pwd to confirm your path ends with /ratelimiter.
- If you see a message about package main not being found, make sure the first line of your file is exactly package main with no extra spaces or characters.
Help me fix my Go run error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved main.go and move on.
ⓧ I'd like to double check the full code
Here is what your main.go should look like:
package main
import "fmt"
func main() {
fmt.Println("Hello, rate limiter!")
}
And your go.mod should look like this:
module ratelimiter
go 1.26
Your Go environment is ready and your project is initialized. Next up, you'll build an HTTP API with two JSON endpoints and discover why it needs protection.
Build an Unprotected API
Your Go environment is set up and your first program ran successfully. Now you need something to protect.
Before you add rate limiting, you need to see why it matters. In this step, you will build a simple HTTP server with two JSON API endpoints, then flood it with requests to see the problem firsthand.
In this step, get ready to:
- Build an HTTP server with two JSON API endpoints.
- Test your endpoints with curl.
- Flood the API to see why rate limiting is necessary.
Build the server with a ping endpoint
Your current main.go just prints a message. You will replace it with an HTTP server that listens for incoming requests and responds with JSON.
- Open main.go in your code editor.
- Delete all the existing code and replace it with the following:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
func main() {
// Create a new request multiplexer for routing
mux := http.NewServeMux()
// Ping endpoint returns a pong message with the current time
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "pong",
"time": time.Now().Format(time.RFC3339),
})
})
// Start the server on port 8080
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
What does this code do?
- http.NewServeMux() creates a request router that maps URL paths to handler functions.
- mux.HandleFunc("GET /api/ping", ...) registers a handler that only responds to GET requests at /api/ping. This method-based routing syntax is built into Go.
- json.NewEncoder(w).Encode(...) converts a Go map into JSON and writes it directly to the HTTP response.
- http.ListenAndServe(":8080", mux) starts the server on port 8080 and blocks the terminal until you stop it.
- Save main.go.
- Start the server by running this command in your terminal:
go run .
You should see Server starting on :8080 printed in the terminal. The server is now running and the terminal is blocked.
Your terminal is busy
The server keeps running until you stop it. You will need a second terminal tab or window to test your endpoints. Open a new terminal tab now.
- In your new terminal tab, test the ping endpoint by running:
curl http://localhost:8080/api/ping
You should see a JSON response with "message": "pong" and a "time" field showing the current timestamp.
Seeing connection refused?
- Make sure the server is still running in your other terminal tab. You should see Server starting on :8080 there.
- Check that you saved main.go before running go run ..
- If another process is already using port 8080, you will see an error in the server terminal. Close that other process first.
Help me debug my Go HTTP server that won't start or accept connections on port 8080.
Add a quote endpoint
Your server has one endpoint. A real API usually has several. You will add a second endpoint that returns a hardcoded quote.
- Switch to the terminal tab where your server is running.
- Press Ctrl+C to stop the server.
- In main.go, find the closing }) of the ping handler. Add the following code below it, before the fmt.Println line:
// Quote endpoint returns a hardcoded inspirational quote
mux.HandleFunc("GET /api/quote", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"quote": "The best way to predict the future is to invent it.",
"author": "Alan Kay",
})
})
What does this code do?
This registers a second handler at GET /api/quote. It follows the same pattern as the ping handler: set the content type to JSON, then encode a Go map into the response body.
- Save main.go.
- Restart the server by running:
go run .
- Switch to your second terminal tab and test the new endpoint by running:
curl http://localhost:8080/api/quote
You should see a JSON response with "quote" and "author" fields.
Seeing a 404 or empty response?
- Make sure you stopped the old server with Ctrl+C and restarted it with go run .. The old server does not know about the new endpoint.
- Check that the quote handler is placed after the ping handler's closing }) and before the fmt.Println line.
- Verify the URL in your curl command matches /api/quote exactly.
Help me debug why my Go HTTP endpoint returns a 404.
Flood the unprotected API
Both endpoints work. But what happens when a client sends a burst of requests? Right now, your server will try to handle every single one.
Before you run the next command, think about this: if you send 15 rapid requests to your server, how many do you think will succeed?
- In your second terminal tab, flood the API by running this command:
for i in $(seq 1 15); do curl -s -o /dev/null -w "Request $i: %{http_code}\n" http://localhost:8080/api/ping; done
Windows users
This command uses bash syntax. If you are on Windows, run it in Git Bash or WSL instead of PowerShell.
Every single request returns status code 200. All 15 succeed.
Why is this a problem?
A real client could send thousands of requests per second and your server would try to handle them all. There is no throttle, no pushback, no limit.
This is exactly the gap that rate limiting fills. In the next step, you will build the algorithm that controls this traffic.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that the server is still running.
ⓧ I'd like to double check the full code
Scroll up and confirm your main.go contains each of these sections in order:
- The import block with encoding/json, fmt, log, net/http, and time.
- The mux := http.NewServeMux() line creating the router.
- The GET /api/ping handler returning a pong message with the current time.
- The GET /api/quote handler returning a hardcoded quote.
- The fmt.Println and http.ListenAndServe(":8080", mux) lines starting the server.
Your API is live, but it has zero defenses. Any client can send unlimited requests and every one gets through. Next up, you will build the token bucket algorithm that decides which requests are allowed and which get turned away.
Implement the Token Bucket Algorithm
In the previous step, you flooded your API with 15 requests and every single one returned 200. That is the problem. Any client can hammer your server as fast as they want, and your server tries to handle it all.
The token bucket algorithm solves this. It works like a bucket that fills with tokens at a steady rate. Each request costs one token. When the bucket is empty, requests are denied until more tokens accumulate.
In this step, get ready to:
- Define a TokenBucket struct with a constructor and an Allow method.
- Write Go tests that verify burst limiting and token refill behavior.
- Run the tests and confirm the algorithm works in isolation.
Define the TokenBucket struct and constructor
The token bucket needs a few pieces of state: how many tokens are in the bucket right now, the rate at which new tokens arrive, the maximum capacity, and the last time tokens were calculated. All of this lives in a single struct.
- Create a new file called ratelimiter.go in your ratelimiter directory and paste the following code:
package main
import (
"sync"
"time"
)
// TokenBucket implements the token bucket rate limiting algorithm.
type TokenBucket struct {
mu sync.Mutex
tokens float64
rate float64
capacity float64
lastTime time.Time
}
// NewTokenBucket creates a rate limiter with the given refill rate
// (tokens per second) and maximum burst capacity.
func NewTokenBucket(rate float64, capacity float64) *TokenBucket {
return &TokenBucket{
tokens: capacity,
rate: rate,
capacity: capacity,
lastTime: time.Now(),
}
}
What does this code do?
- mu sync.Mutex protects the bucket from race conditions. Go's HTTP server handles each request in its own goroutine, so multiple requests can try to grab tokens at the same time.
- tokens tracks the current number of tokens. rate is how many tokens refill per second. capacity is the maximum the bucket can hold.
- NewTokenBucket starts the bucket full. A brand new limiter lets the first burst of requests through before the steady rate kicks in.
- Save ratelimiter.go.
- Confirm the file was created by running:
ls
You should see go.mod, main.go, and ratelimiter.go listed in the output.
Don't see ratelimiter.go?
- Make sure you created the file in the ratelimiter directory, not a subfolder or parent folder.
- Check that you saved the file after pasting the code.
- help me find where my ratelimiter.go file was created
Add the Allow method
The struct holds state, but the real logic lives in the Allow method. Every time a request arrives, Allow calculates how many tokens have accumulated since the last call, refills the bucket, and tries to consume one token.
- In ratelimiter.go, add the following Allow method below the NewTokenBucket function:
// Allow checks if a request should be allowed.
// Returns whether the request is allowed and how many tokens remain.
func (tb *TokenBucket) Allow() (bool, int) {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastTime).Seconds()
tb.lastTime = now
// Refill tokens based on elapsed time
tb.tokens += elapsed * tb.rate
if tb.tokens > tb.capacity {
tb.tokens = tb.capacity
}
// Reject if not enough tokens
if tb.tokens < 1 {
return false, 0
}
// Consume one token
tb.tokens--
return true, int(tb.tokens)
}
What does the Allow method do?
- tb.mu.Lock() and defer tb.mu.Unlock() ensure only one goroutine reads or writes the token count at a time. Without this, two concurrent requests could both see one remaining token and both consume it.
- The elapsed time since the last call determines how many new tokens to add. Multiplying elapsed * tb.rate converts seconds into tokens. The result is capped at capacity so the bucket never overflows.
- If at least one token is available, Allow consumes it and returns true. Otherwise it returns false and the request should be rejected.
- Save ratelimiter.go.
- Confirm the code compiles by running:
go build .
If the command finishes with no output, your code compiles successfully. Any syntax errors will be printed here.
Seeing a compile error?
- Check that the Allow method is outside the NewTokenBucket function, not nested inside it. Methods in Go must be defined at the package level.
- Verify that the package declaration at the top of ratelimiter.go says package main, matching main.go.
- help me fix a compile error in my Go rate limiter
Why is this the same algorithm AWS and Stripe use?
The token bucket is the most widely adopted rate limiting algorithm in production. AWS, Stripe, and GitHub all use it to protect their APIs.
The key advantage is that it handles bursts naturally. A client that has been idle for a while accumulates tokens and can send a quick burst of requests. A client sending continuously hits the steady-rate limit. This matches real-world traffic patterns better than a strict "one request per interval" approach.
Write a test for burst limiting
The algorithm compiles, but you need proof it works. Go has a built-in testing framework. You write test functions in files that end with _test.go and run them with go test.
The first test verifies burst limiting. If you create a bucket with a capacity of 5, exactly 5 requests should be allowed. The 6th should be denied.
- Create a new file called ratelimiter_test.go in your ratelimiter directory and paste the following code:
package main
import (
"testing"
"time"
)
func TestTokenBucket_AllowsBurst(t *testing.T) {
limiter := NewTokenBucket(2, 5)
for i := 1; i <= 5; i++ {
allowed, remaining := limiter.Allow()
if !allowed {
t.Errorf("Request %d: expected allowed, got denied", i)
}
t.Logf("Request %d: allowed=%v remaining=%d", i, allowed, remaining)
}
allowed, _ := limiter.Allow()
if allowed {
t.Error("Request 6: expected denied, got allowed")
}
t.Log("Request 6: correctly denied")
}
What does this test do?
- NewTokenBucket(2, 5) creates a bucket with a refill rate of 2 tokens per second and a capacity of 5. The bucket starts full with 5 tokens.
- The loop calls Allow() five times. Each call should succeed and consume one token. After five calls, the bucket is empty.
- The 6th call should be denied because there are no tokens left. If it is allowed, the test fails.
- Save ratelimiter_test.go.
- Run just this one test by running:
go test -v -run TestTokenBucket_AllowsBurst
You should see each of the five requests logged as allowed, then the 6th correctly denied, followed by --- PASS.
Test failing or not found?
- Make sure the file is named ratelimiter_test.go (not ratelimiter_tests.go). Go requires the _test.go suffix to discover test files.
- Check that the test function name starts with Test (capital T). Go only runs functions matching this prefix.
- help me debug my failing Go test for the token bucket
Write a test for token refill
Burst limiting is only half the picture. The bucket must also refill over time. If you drain it and wait, new tokens should appear based on the rate you configured.
- In ratelimiter_test.go, add the following test function below TestTokenBucket_AllowsBurst:
func TestTokenBucket_RefillsOverTime(t *testing.T) {
limiter := NewTokenBucket(10, 5)
for i := 0; i < 5; i++ {
limiter.Allow()
}
time.Sleep(500 * time.Millisecond)
allowed, _ := limiter.Allow()
if !allowed {
t.Error("Expected request to be allowed after refill")
}
t.Log("Request allowed after refill period")
}
What does this test do?
- NewTokenBucket(10, 5) creates a bucket with a fast refill rate of 10 tokens per second and a capacity of 5.
- The loop drains all 5 tokens immediately. The bucket is now empty.
- time.Sleep(500 * time.Millisecond) waits half a second. At 10 tokens per second, roughly 5 new tokens should have accumulated. The next Allow() call should succeed.
- Save ratelimiter_test.go.
Run both tests
Before you run this, think about what you expect to see. You have two tests: one that fires 6 requests at a bucket that holds 5, and one that drains the bucket and waits for a refill. What should happen?
- Run both tests by running:
go test -v
Both tests pass. The burst test shows 5 allowed requests followed by a denied 6th. The refill test shows a request allowed after the 500ms wait.
Seeing a test failure?
- If TestTokenBucket_RefillsOverTime fails, make sure your Allow method updates tb.lastTime = now on every call. Without this, elapsed time is always calculated from the initial creation time.
- If TestTokenBucket_AllowsBurst fails on the 6th request, check that Allow decrements tb.tokens-- before returning. If you forget this, the bucket never empties.
- help me debug my failing Go tests for the token bucket rate limiter
✔️ Awesome, I've got everything!
Great. Double check you have saved both ratelimiter.go and ratelimiter_test.go.
ⓧ I'd like to double check the full code
Your ratelimiter.go should contain these sections in order. Scroll up and confirm each one is present:
- package main declaration and imports (sync and time).
- TokenBucket struct with five fields: mu, tokens, rate, capacity, lastTime.
- NewTokenBucket constructor that returns a full bucket.
- Allow() method that locks the mutex, refills tokens, and consumes one.
Your ratelimiter_test.go should contain these sections in order:
- package main declaration and imports (testing and time).
- TestTokenBucket_AllowsBurst function (rate=2, capacity=5, 5 allowed then 6th denied).
- TestTokenBucket_RefillsOverTime function (rate=10, capacity=5, drain, sleep 500ms, then allowed).
Your token bucket algorithm is proven. It limits bursts and refills over time, all verified by tests. Next up, you will wire this algorithm into your HTTP server so every request passes through the rate limiter before reaching your handlers.
Wire Rate Limiting as HTTP Middleware
Your token bucket algorithm is tested and working. Both tests confirm it correctly limits bursts and refills tokens over time.
Your API endpoints still accept every request though. The RateLimitMiddleware function you wrote in the previous step is ready to intercept requests, but it is not connected to the server yet. In this step, you will wire it in and watch requests get rejected.
In this step, get ready to:
- Connect the token bucket rate limiter to your HTTP server as middleware.
- Flood your API and see requests get rejected with 429 status codes.
- Inspect rate limit headers in the response.
Connect the middleware to your server
The RateLimitMiddleware function in ratelimiter.go wraps any http.Handler with rate limiting logic. To activate it, you create a token bucket and wrap your mux before passing it to http.ListenAndServe.
- In main.go, find the last two lines of the main() function:
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
- Add two new lines directly above fmt.Println to create a rate limiter and wrap the mux.
- Change mux to handler in the http.ListenAndServe call.
- The end of your main() function should now look like this:
// Create a rate limiter: 5 tokens per second, 10 max burst
limiter := NewTokenBucket(5, 10)
handler := RateLimitMiddleware(limiter, mux)
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", handler))
What does this code do?
NewTokenBucket(5, 10) creates a bucket that refills at 5 tokens per second and holds a maximum of 10. This means a client can burst up to 10 requests instantly, then is limited to 5 per second after that.
RateLimitMiddleware(limiter, mux) wraps the original mux so every incoming request passes through the rate limiter before reaching your /api/ping or /api/quote handlers. Passing handler to http.ListenAndServe instead of mux activates the middleware.
What is HTTP middleware?
Middleware is a function that sits between the incoming request and your handler. It can inspect, modify, or reject the request before the handler ever sees it. In Go, the pattern is a function that takes an http.Handler and returns a new http.Handler that wraps extra behavior around the original.
This is the same pattern used by production APIs for authentication, logging, CORS, and compression.
- Save main.go.
- If your server is still running from Step 2, stop it by pressing Ctrl+C in the terminal.
- Start the updated server by running:
go run .
You should see Server starting on :8080 printed in the terminal.
- Open a second terminal window and test a single request by running:
curl http://localhost:8080/api/ping
You should see the familiar JSON response with "message": "pong". The endpoint still works, but now every request passes through the rate limiter first.
Not seeing the pong response?
- Make sure you stopped the old server before running go run . again. If port 8080 is already in use, the new server cannot start.
- Check that you changed mux to handler in the http.ListenAndServe call. If you still pass mux, the middleware is bypassed entirely.
- If you see a compilation error, make sure the two new lines are inside the main() function body, above fmt.Println.
My server starts but curl gets no response.
Flood your API and watch the rate limiter kick in
A single request succeeds because the bucket starts full with 10 tokens. The real test is sending more requests than the bucket can handle.
Before you run the next command, think about this: you have a bucket with 10 tokens and you are about to send 15 requests in rapid succession. How many do you think will succeed?
- Flood the API with 15 rapid requests by running this command in your second terminal:
for i in $(seq 1 15); do curl -s -o /dev/null -w "Request $i: %{http_code}\n" http://localhost:8080/api/ping; done
The first 10 requests return status code 200. Requests 11 through 15 return 429. Your rate limiter is working.
What does this command do?
- for i in $(seq 1 15) loops 15 times.
- -s silences the progress bar.
- -o /dev/null discards the response body so you only see the status code.
- -w "Request $i: %{http_code}\n" prints the request number and its HTTP status code.
- Now inspect the full response headers by running:
curl -i http://localhost:8080/api/ping
The -i flag includes response headers in the output alongside the body. You should see X-RateLimit-Limit: 10 and X-RateLimit-Remaining showing how many tokens are left in the bucket.
What do these headers tell the client?
- X-RateLimit-Limit tells the client the maximum number of requests allowed (the bucket capacity).
- X-RateLimit-Remaining tells the client how many requests they can still make before being throttled.
- Retry-After (only on 429 responses) tells the client how many seconds to wait before trying again.
These are de facto standard headers used by APIs from AWS, Stripe, and GitHub to communicate rate limit status.
Verify tokens refill over time
The token bucket refills at 5 tokens per second. If you wait a couple of seconds, the bucket should have tokens again.
- Wait 2-3 seconds, then run the flood test again:
for i in $(seq 1 15); do curl -s -o /dev/null -w "Request $i: %{http_code}\n" http://localhost:8080/api/ping; done
You should see the first several requests return 200 again. The bucket refilled while you waited. This is the token bucket algorithm in action. It allows bursts up to the capacity, enforces a steady rate, and recovers over time.
All 15 requests still returning 429?
- Make sure you waited at least 2 seconds between flood tests. At 5 tokens per second, 2 seconds adds 10 tokens back to the bucket.
- If the bucket never seems to refill, check the Allow() method in ratelimiter.go. The line tb.tokens += elapsed * tb.rate calculates new tokens based on elapsed time. If lastTime is not being updated, the refill calculation breaks.
My rate limiter never refills tokens between requests.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go.
ⓧ I'd like to double check the full code
Here is the complete main.go at this point:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "pong",
"time": time.Now().Format(time.RFC3339),
})
})
mux.HandleFunc("GET /api/quote", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"quote": "The best way to predict the future is to invent it.",
"author": "Alan Kay",
})
})
limiter := NewTokenBucket(5, 10)
handler := RateLimitMiddleware(limiter, mux)
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}
The ratelimiter.go and ratelimiter_test.go files are unchanged from the previous step.
Secret mission
Per-IP Rate Limiting
Your rate limiter works, but it has a flaw. Every client shares the same token bucket. One heavy user can burn through all the tokens and lock out everyone else. Can you upgrade to per-IP rate limiting so each client gets their own bucket?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or remove them.
Resources you used:
- The ratelimiter project directory on your Desktop.
- Go 1.26 installation.
Keep everything running
No action needed. Choose this if you're still actively experimenting with your rate limiter or want to build on it further.
- Your ratelimiter project files stay on your Desktop.
- Go stays installed on your machine. There are no ongoing costs.
- If your server is still running, you can stop it at any time by pressing Ctrl+C in the terminal.
Pause - I'll come back to this later
Stop the running server to free up port 8080. Your project files stay on your Desktop for when you return.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Your ratelimiter folder and all its files remain on your Desktop. To resume, open a terminal, run cd ~/Desktop/ratelimiter, and start the server again with go run ..
Delete - I don't want to use this again
Remove all project files from your machine. If you also want to remove Go itself, follow the official uninstall instructions.
- If your server is still running, switch to its terminal and press Ctrl+C to stop it.
Delete the project directory by running the command for your operating system.
macOS
- Remove the project folder by running:
rm -rf ~/Desktop/ratelimiter
- Confirm the folder is gone by running:
ls ~/Desktop | grep ratelimiter
You should see no output, which means the folder has been deleted.
Windows
- Remove the project folder by running:
rmdir /s /q "%USERPROFILE%\Desktop\ratelimiter"
- Confirm the folder is gone by running:
dir "%USERPROFILE%\Desktop\ratelimiter"
You should see a "File Not Found" message, which means the folder has been deleted.
To uninstall Go from your machine, follow the official uninstall instructions on go.dev.
Nice Work!
Nice Work!
You just built a production-grade rate limiter from scratch using nothing but Go's standard library. This is the same algorithm that protects APIs at AWS, Stripe, and GitHub.
You've learned how to:
- Implement the token bucket algorithm with mutex-based concurrency, time-based token refill, and configurable rate and burst capacity.
- Wire a rate limiter as HTTP middleware using Go's http.Handler wrapping pattern, returning 429 Too Many Requests with standard headers like Retry-After and X-RateLimit-Remaining.
- Write Go tests that verify burst limiting and token refill behavior using go test -v.
- Secret Mission: Upgraded to per-IP rate limiting with separate token buckets per client and a cleanup goroutine to prevent memory leaks.
Ready to quiz yourself?