Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 032 / 100

Redis with Go: concepts, trade-offs and production lessons

Caching and rate limiting.

Expona Admin8 min read

Redis with Go: concepts, trade-offs and production lessons

Redis is one of those tools that feels simple until you deploy it at scale. We've seen teams burn through memory, lose data they thought was safe, and create thundering herds when cache expires. This guide covers Redis with Go: concepts, trade-offs and production lessons on caching, rate limiting, and distributed locks—the three patterns that show up in nearly every production API.

Why Redis matters in production APIs

Redis sits between your application and the hard problems: database latency, API quota enforcement, and coordinating work across multiple servers. Used well, it's invisible. Used poorly, it becomes a bottleneck or a source of subtle bugs.

In this series, we're building production APIs, not toy services. That means understanding not just how to use Redis, but when to use it, what to watch for, and what happens when it fails.

go-redis client — Reuse one client with built-in pooling

The first mistake we see is creating a new Redis client for each request. It's almost always wrong.

The go-redis package (redis.NewClient) manages a connection pool for you. Create one client at startup, reuse it for the lifetime of your application, and let it handle pooling and reconnection logic.

go
package main

import (
	"context"
	"github.com/redis/go-redis/v9"
)

var rdb *redis.Client

func init() {
	rdb = redis.NewClient(&redis.Options{
		Addr:         "localhost:6379",
		PoolSize:     10,
		MinIdleConns: 5,
	})
}

func main() {
	// Reuse rdb for all requests
	ctx := context.Background()
	err := rdb.Set(ctx, "key", "value", 0).Err()
	if err != nil {
		panic(err)
	}
}

Key settings to tune: PoolSize controls max connections; MinIdleConns keeps connections warm so you don't wait for dial on every request. Start conservative (10 pool size, 5 idle) and measure under load.

Connection lifecycle: The client handles PING and reconnection automatically. However, if Redis is down at startup, you'll want to check connectivity or defer the check to the first actual use. In production, we've seen teams add a health check endpoint that pings Redis explicitly.

go
func healthCheck(ctx context.Context) error {
	return rdb.Ping(ctx).Err()
}

Context management: Every Redis call takes a context. Honor timeouts set by the caller. If a request context is cancelled, the Redis call should fail fast rather than hang.

Cache-aside — Read from cache, fall back to DB, then set with TTL

Cache-aside (or lazy loading) is the simplest caching pattern and the one we use most often. Check the cache first; if it misses, fetch from the database and populate the cache on the way out.

Consider a user service. Fetching a user by ID happens frequently. Without caching, your database takes the load every time.

go
func getUserByID(ctx context.Context, userID string) (*User, error) {
	// Try cache first
	cacheKey := fmt.Sprintf("user:%s", userID)
	val, err := rdb.Get(ctx, cacheKey).Result()
	if err == nil {
		// Cache hit
		var user User
		if err := json.Unmarshal([]byte(val), &user); err != nil {
			return nil, err
		}
		return &user, nil
	}
	if err != redis.Nil {
		// Redis error (not miss), but don't fail the request
		// Log and continue to database
	}

	// Cache miss or error, fetch from DB
	user, err := db.GetUser(ctx, userID)
	if err != nil {
		return nil, err
	}

	// Set in cache with 1-hour TTL
	data, _ := json.Marshal(user)
	rdb.Set(ctx, cacheKey, data, time.Hour)

	return user, nil
}

TTL trade-offs: Shorter TTLs (5–30 minutes) keep data fresher but increase miss rates and database load. Longer TTLs (1–24 hours) reduce database hits but risk stale data. For user profiles, 1 hour is typical. For inventory or pricing, consider the cost of staleness.

Stampede prevention: If 100 requests come in for the same missing key simultaneously, all hit the database. This is the "thundering herd." One mitigation: probabilistic early expiration (refresh a key before it expires if you detect high traffic). Another: use a distributed lock (see below) to ensure only one process populates the cache.

What to cache and what not to: Cache reads that are expensive and safe to be slightly stale. Don't cache user authorization state or payment amounts without very short TTLs. Don't cache data that changes frequently (every few seconds) unless you have a good invalidation strategy.

Rate limiting — INCR with EXPIRE for simple fixed windows

Rate limiting is straightforward with Redis. For each user or IP, store a counter that increments on each request. If it exceeds the limit within a time window, reject the request.

The simplest approach uses INCR and EXPIRE:

go
func isRateLimited(ctx context.Context, userID string, limit int, windowSec int) (bool, error) {
	key := fmt.Sprintf("ratelimit:%s", userID)
	
	// Increment counter
	count, err := rdb.Incr(ctx, key).Result()
	if err != nil {
		return false, err // Fail open: allow the request
	}

	// Set expiration on first request in window
	if count == 1 {
		rdb.Expire(ctx, key, time.Duration(windowSec)*time.Second)
	}

	return count > int64(limit), nil
}

// In your handler
if limited, err := isRateLimited(ctx, userID, 100, 60); limited {
	return http.StatusTooManyRequests, nil
}

Fixed window gotcha: This approach is called fixed-window rate limiting. If your window is 60 seconds and a user makes 100 requests in the last 10 seconds of window 1, the counter resets at window 2, allowing them another 100 requests immediately. This is a known edge case. Sliding-window rate limiting is more precise but requires more Redis calls.

Fail-open philosophy: If Redis is down, should you reject requests or allow them? Most teams allow requests (fail open) rather than deny all traffic due to a cache outage. Log the error and monitor it, but keep the API running.

Distribute across multiple keys: If you want per-endpoint or per-method limits, create separate keys: ratelimit:user:123:POST:/api/orders. This lets you tune limits per operation.

Distributed locks — Use carefully, with expirations

Sometimes you need to ensure only one process does something at a time. A typical scenario: importing bulk data, sending a batch email, or reconciling balances.

Redis SET with NX (not exists) and EX (expiration) is a lightweight lock:

go
func acquireLock(ctx context.Context, lockKey string, ttlSec int) (bool, error) {
	ok, err := rdb.SetNX(ctx, lockKey, "locked", time.Duration(ttlSec)*time.Second).Result()
	return ok, err
}

func releaseLock(ctx context.Context, lockKey string) error {
	return rdb.Del(ctx, lockKey).Err()
}

// In your batch job
if ok, _ := acquireLock(ctx, "batch:import", 300); !ok {
	// Another process is running the import
	return fmt.Errorf("import already in progress")
}
defer releaseLock(ctx, "batch:import")

// Do the work
importData(ctx)

Always set an expiration. If the process crashes while holding the lock, you don't want it held forever. A 5–10 minute TTL is typical for long-running jobs. If the job takes longer, use periodic refresh (update the TTL) rather than a longer initial TTL.

Limitations: Redis locks are not as safe as distributed locks from etcd or Consul. If your Redis instance fails over, a lock can be lost. For critical operations (like financial transfers), use a proper distributed consensus system. For convenience operations (like bulk imports), Redis locks are fine.

Avoiding deadlock: If you hold multiple locks, always acquire them in the same order to avoid circular waits. If a process dies while holding a lock, the TTL ensures it expires and other processes can proceed after a delay.

Common mistakes

  • Creating a new client per request: Wastes connections and memory. Create one client at init.
  • Ignoring Redis errors as "cache miss": A network error is not the same as a key not existing. Handle errors explicitly and log them.
  • Setting TTLs too long: Stale data can cause real problems. Bias toward shorter TTLs and handle misses gracefully.
  • Forgetting expiration on locks: A crashed process holding a lock forever is a common cause of production hangs.
  • Assuming Redis is always available: It's fast and reliable, but network and disk failures happen. Design your app to degrade gracefully.
  • Overloading a single Redis instance: Redis is single-threaded. If you're using it heavily, split by domain (user cache on one instance, session on another) or use Redis Cluster.

Key takeaways

  • Create one redis.Client at startup, reuse it throughout the app, and let connection pooling handle concurrency.
  • Cache-aside is simple and safe: try cache, fall back to DB, set with a reasonable TTL. Don't try to be too clever.
  • Rate limiting with INCR and EXPIRE is straightforward for fixed-window limits. Know its edge cases and choose sliding-window if precision matters.
  • Distributed locks via Redis are convenient but not bulletproof. Always set expiration and use them for convenience, not critical safety.
  • Handle Redis errors gracefully—fail open, log, monitor. A cache outage should not break your API.
  • Measure and tune connection pool size and TTLs under realistic load. What works for dev will feel very different at production scale.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles