DEV Community

Abhishek Sharma
Abhishek Sharma

Posted on

I Built Rate Limiting From Scratch in Go — Then Replaced It With Redis

In Part 5, I added pagination and in-memory caching. Both worked fine — until I thought about what happens with more than one server.

Three days, two rewrites, one lesson: in-memory is a local solution to a global problem.

Day 18: Rate Limiting From Scratch

I wanted to limit each IP to 100 requests per minute. The algorithm is called a fixed window — count requests in a time window, reset when the window expires.

I built it with a map and a sync.RWMutex:

type RateLimitEntry struct {
    count       int
    WindowStart time.Time
}

type RateLimit struct {
    users map[string]RateLimitEntry
    mu    sync.RWMutex
}

func (rl *RateLimit) IsAllowed(key string, limit int, window time.Duration) bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    entry, exists := rl.users[key]
    now := time.Now()

    // New IP or expired window → start fresh
    if !exists || now.After(entry.WindowStart.Add(window)) {
        rl.users[key] = RateLimitEntry{count: 1, WindowStart: now}
        return true
    }

    if entry.count >= limit {
        return false // blocked
    }

    entry.count++
    rl.users[key] = entry
    return true
}
Enter fullscreen mode Exit fullscreen mode

Wiring it up as middleware was the satisfying part — one function wraps another:

http.HandleFunc("/entries", handlers.RateLimitMiddleware(
    handlers.LoggingMiddleware(
        handlers.AuthMiddleware(handler),
    ),
))
Enter fullscreen mode Exit fullscreen mode

The API now returns 429 Too Many Requests if you hammer it. Done. Except...

The Problem I Didn't Solve

I was writing system design notes alongside the code, and I drew this:

Load Balancer
    /        \
Server A    Server B
count: 50   count: 50   ← each has its OWN memory
Enter fullscreen mode Exit fullscreen mode

User makes 100 requests. 50 go to A, 50 to B. Each server thinks "only 50 — under limit!". The user bypassed rate limiting entirely.

Same problem with caching: every server has its own in-memory cache. One server caches a count. Another server doesn't. They disagree. Your data is inconsistent.

In-memory solutions break the moment you run more than one instance.

Days 19-20: Replacing Everything With Redis

Redis is a shared, external store. Both servers read from and write to the same place. Problem solved.

Cache before (in-memory):

type Cache struct {
    data map[string]CacheEntry
    mu   sync.RWMutex
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    entry, exists := c.data[key]
    if !exists || time.Now().After(entry.Expiration) {
        return nil, false
    }
    return entry.value, true
}
Enter fullscreen mode Exit fullscreen mode

Cache after (Redis):

func Get(key string) (string, bool) {
    result, err := redis.Client.Get(context.Background(), key).Result()
    if err == goredis.Nil {
        return "", false // key doesn't exist
    }
    if err != nil {
        return "", false // other error
    }
    return result, true
}
Enter fullscreen mode Exit fullscreen mode

Same function signature. The caller — entries.go — didn't change at all. That's separation of concerns working exactly as intended.

Rate limiting before (in-memory): the IsAllowed function with sync.RWMutex above.

Rate limiting after (Redis):

func IsAllowed(key string, limit int, window time.Duration) bool {
    redisKey := "ratelimit:" + key

    // Atomic increment — no mutex needed
    count, err := redis.Client.Incr(context.Background(), redisKey).Result()
    if err != nil {
        return true // fail open if Redis is down
    }

    // First request in window → set expiry
    if count == 1 {
        redis.Client.Expire(context.Background(), redisKey, window)
    }

    return count <= int64(limit)
}
Enter fullscreen mode Exit fullscreen mode

INCR is atomic in Redis. No mutex. No struct. No map. Redis handles the concurrency.

What I Learned

sync.RWMutex is for single-process concurrency. Multiple goroutines in one server — yes. Multiple servers — no. When you need distributed state, you need a distributed store.

The same API surface, different backend. cache.Get(key) works whether the backend is a map or Redis. The handlers don't know or care. That's the value of keeping the interface clean.

Fail open vs fail closed. If Redis goes down, my rate limiter returns true (allow the request). This is a deliberate choice — I'd rather serve traffic than block everyone because of an infra failure. For some use cases (auth, payments) you'd fail closed. Know which you need.

goredis.Nil is a sentinel error. When a key doesn't exist, Redis returns a specific error value — not an empty string. This tripped me up the first time.

The Architecture Now

Load Balancer
    /        \
Server A    Server B
    \        /
      Redis
   (shared state)
Enter fullscreen mode Exit fullscreen mode

One Redis. Both servers read the same cache, increment the same counters. Scale horizontally without breaking anything.

What's Next

In Part 7, I'll cover graceful shutdown and health checks — what happens when your server needs to stop without dropping requests in flight, and how to expose a /health endpoint that actually checks your dependencies.

This is Part 6 of "Learning Go in Public". Part 1 | Part 2 | Part 3 | Part 4 | Part 5

Top comments (0)