Skip to main content

Fixed Window Rate Limit with Golang & Redis

·1056 words·5 mins
Sebastian Scheibe
Author
Sebastian Scheibe
Table of Contents

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 .

References
#