Introduction #
Imagine you have a resource that can only handle a limited number of requests per second.
How do you ensure it doesn’t get overwhelmed?
One solution is rate-limiting — controlling how many requests are allowed in a given time frame.
In this article, I’ll show how to implement a simple rate limiter using Redis and Golang.
Rate-Limiting Algorithms #
There are several types of rate-limiting algorithms — two common ones are fixed-window and token bucket.
We’ll focus on fixed-window since it’s straightforward to implement.
Fixed-Window #
A key is created for a fixed time window (e.g. 1 second).
Each request increments a counter for that key.
If the counter exceeds a defined limit, further requests are rejected.
The key expires after the window ends.
Token Bucket #
Think of a bucket that holds 10 tokens.
Each second, tokens are added to the bucket until it’s full.
Every request consumes a token.
When no tokens remain, requests are rejected until more tokens arrive.
Redis Setup #
We’ll use Redis to store counters.
Make sure you have a Redis instance running on localhost:6379
.
If you don’t have Redis installed, use this docker-compose.yml
:
services:
cache:
image: redis:7.4-alpine
restart: always
ports:
- '6379:6379'
volumes:
- cache:/data
volumes:
cache:
driver: local
Start Redis with:
docker compose up
Expected output:
docker compose up
[+] Running 1/1
✔ Container rate-limit-example-golang-cache-1 Created 0.0s
Attaching to cache-1
cache-1 | 1:C 02 May 2025 08:51:44.153 * oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
cache-1 | 1:C 02 May 2025 08:51:44.153 * Redis version=7.4.3, bits=64, commit=00000000, modified=0, pid=1, just started
cache-1 | 1:C 02 May 2025 08:51:44.153 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
cache-1 | 1:M 02 May 2025 08:51:44.154 * monotonic clock: POSIX clock_gettime
cache-1 | 1:M 02 May 2025 08:51:44.156 * Running mode=standalone, port=6379.
cache-1 | 1:M 02 May 2025 08:51:44.156 * Server initialized
cache-1 | 1:M 02 May 2025 08:51:44.156 * Loading RDB produced by version 7.4.3
cache-1 | 1:M 02 May 2025 08:51:44.156 * RDB age 4 seconds
cache-1 | 1:M 02 May 2025 08:51:44.156 * RDB memory usage when created 1.43 Mb
cache-1 | 1:M 02 May 2025 08:51:44.156 * Done loading RDB, keys loaded: 0, keys expired: 0.
cache-1 | 1:M 02 May 2025 08:51:44.156 * DB loaded from disk: 0.000 seconds
cache-1 | 1:M 02 May 2025 08:51:44.156 * Ready to accept connections tcp
Keep that terminal open.
Implementing the Rate Limiter in Golang #
Initialize your project:
go mod init test
go get github.com/go-redis/redis/v8
go mod tidy
Create a main.go
file:
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"github.com/go-redis/redis/v8"
)
var (
redisClient *redis.Client
redisKeyPrefix = "api_rate_limit"
rateLimit = 10 // Requests per second
)
func init() {
redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Replace with your Redis address
Password: "", // No password set
DB: 0, // Use default DB
})
ctx := context.Background()
_, err := redisClient.Ping(ctx).Result()
if err != nil {
log.Fatalf("Failed to connect to Redis: %v", err)
}
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
// Check and consume a token from Redis
allowed, err := checkAndConsumeToken2(ctx)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
log.Printf("Error checking/consuming token: %v", err)
return
}
if !allowed {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
// Make the request to the 3rd party API
// ... (replace with your API call)
fmt.Fprintf(w, "API response")
}
func checkAndConsumeToken(ctx context.Context) (bool, error) {
now := time.Now()
// This defines the time-window, in this case 1 second
currentSecond := now.Unix()
// replace with more specific identifier, for example user_id if necessary
identifier := "global"
redisKey := fmt.Sprintf("%s:%s:%d", redisKeyPrefix, identifier, currentSecond)
result, err := redisClient.Incr(ctx, redisKey).Result()
if err != nil {
return false, err
}
if result >= int64(rateLimit) {
return false, nil
}
// Expire the key after 2 seconds
redisClient.Expire(ctx, redisKey, time.Second*2)
return true, nil
}
func main() {
http.HandleFunc("/", handleRequest)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Testing the Rate Limiter #
Start the app:
go run ./main.go
Make test requests:
curl http://localhost:8080
Expected output:
API response
Load Testing with k6 #
k6 by Grafana is a great tool for HTTP load testing.
Install k6 and create a test script test.js:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 1, // Start with a few virtual users to generate load
duration: '10s', // Duration of the test
thresholds: {
http_req_failed: ['rate<1'],
http_req_duration: ['p(95)<200'], // 95% of requests should complete within 200ms
'http_reqs{status:200}': ['rate>9', 'rate<10'], // Adjust expectations based on the limit
'http_reqs{status:429}': ['rate>1'], // Expect a rate of 429s when overloaded
},
};
export default function () {
const res = http.get('http://localhost:8080/');
check(res, {
'status is 200 or 429': (r) => r.status === 200 || r.status === 429,
});
}
Run the test:
k6 run test.js
Expected output:
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: test.js
output: -
scenarios: (100.00%) 1 scenario, 1 max VUs, 40s max duration (incl. graceful stop):
* default: 1 looping VUs for 10s (gracefulStop: 30s)
✓ status is 200 or 429
checks.........................: 100.00% 31528 out of 31528
data_received..................: 5.8 MB 576 kB/s
data_sent......................: 2.5 MB 252 kB/s
http_req_blocked...............: avg=1.05µs min=0s med=1µs max=1.36ms p(90)=1µs p(95)=2µs
http_req_connecting............: avg=6ns min=0s med=0s max=215µs p(90)=0s p(95)=0s
✓ http_req_duration..............: avg=289.77µs min=136µs med=258µs max=15.09ms p(90)=380µs p(95)=465µs
{ expected_response:true }...: avg=642.34µs min=325µs med=494µs max=4.12ms p(90)=1.02ms p(95)=1.4ms
✓ http_req_failed................: 99.68% 31429 out of 31528
http_req_receiving.............: avg=13.86µs min=6µs med=12µs max=1.21ms p(90)=19µs p(95)=25µs
http_req_sending...............: avg=3.16µs min=1µs med=2µs max=777µs p(90)=4µs p(95)=6µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=272.74µs min=126µs med=243µs max=15.01ms p(90)=358µs p(95)=435µs
http_reqs......................: 31528 3152.691547/s
✓ { status:200 }...............: 99 9.899659/s
✓ { status:429 }...............: 31429 3142.791888/s
iteration_duration.............: avg=314.16µs min=152.12µs med=278.54µs max=15.16ms p(90)=413.01µs p(95)=509.53µs
iterations.....................: 31528 3152.691547/s
vus............................: 1 min=1 max=1
vus_max........................: 1 min=1 max=1
running (10.0s), 0/1 VUs, 31528 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs 10s
You should see a mix of 200 and 429 responses depending on load — confirming the rate limit works.
Conclusion #
You’ve now seen how to implement a fixed-window rate limiter in Go using Redis. You also learned how to validate it with a tool like k6.
Let me know what you think — feel free to email me .