The missing layer between your app and the database
Your API starts fast. The database is quiet. Then traffic doubles. Response times creep from fifty milliseconds to eight hundred. The database CPU spikes. You did not write bad code. You just hit the wall where disk I/O meets concurrent requests. The fix is rarely a better query. It is a cache. Redis sits in front of your database, holding frequently read data in memory. Go talks to it over TCP. The pattern is straightforward, but the details around context, timeouts, and cache misses determine whether your cache speeds things up or becomes a silent bottleneck.
What caching actually buys you
Think of Redis as a whiteboard in a busy kitchen. The database is the walk-in freezer. Every time a chef needs a specific spice blend, they could walk to the freezer, open the heavy door, wait for the light to turn on, grab the jar, and walk back. Or they could check the whiteboard first. If the blend is written down, they use it. If not, they go to the freezer, grab it, and write the recipe on the whiteboard for the next person. The whiteboard has limited space, so old recipes get erased after a while. That is the cache-aside pattern. Your application checks the cache first. On a miss, it hits the database, writes the result to Redis with an expiration time, and returns the data. The expiration prevents stale data from living forever.
Caching trades consistency for latency. You accept that the cache might briefly show outdated information while the database holds the truth. In exchange, you reduce disk reads, lower database connection pressure, and keep your application responsive under load. The trade-off is acceptable for user profiles, product catalogs, session tokens, and configuration data. It is not acceptable for bank balances or inventory counts where every write must be immediately visible.
The simplest Redis client setup
Here is the baseline client setup. It connects to a local Redis instance, stores a value with a time-to-live, and retrieves it.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
// context.Background() starts the call chain. Redis respects cancellation.
ctx := context.Background()
// NewClient creates a connection pool under the hood.
// Addr points to the Redis server. DB selects the logical database.
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
// Set stores the key-value pair. The third argument sets a 5-minute TTL.
// .Err() extracts the error from the Cmd wrapper.
err := rdb.Set(ctx, "user:1", "Alice", 5*time.Minute).Err()
if err != nil {
// if err != nil is verbose by design. It makes the unhappy path visible.
log.Fatal(err)
}
// Get retrieves the value. .Result() returns the string and any error.
val, err := rdb.Get(ctx, "user:1").Result()
if err != nil {
log.Fatal(err)
}
fmt.Println("user:1 is", val)
// Close releases the underlying connection pool resources.
rdb.Close()
}
Install the library with go get github.com/redis/go-redis/v9. The client handles connection pooling automatically. You do not need to manage individual TCP sockets. The pool grows and shrinks based on concurrent requests, capped by the MaxConns setting in Options. Create the client once at startup and pass it through your dependency chain. Do not create a new client per request.
Walking through the request cycle
When redis.NewClient runs, it does not immediately open a connection to Redis. It initializes a pool of zero connections. The first call to Set or Get triggers the pool to dial the server. Subsequent calls reuse existing idle connections instead of paying the TCP handshake cost. This is why you share the client across handlers. The pool tracks idle connections, active connections, and dialing connections. If all connections are busy, new requests block until one frees up or the dial timeout fires.
The context.Context parameter travels with every Redis command. If your HTTP handler times out or the client cancels the request, the context signals the Redis client to abort the pending network operation. The client returns a context.Canceled or context.DeadlineExceeded error. You check it the same way you check any other error. The convention is strict: context always goes first, named ctx. Functions that accept a context must respect cancellation.
Redis commands return a Cmd struct, not the raw value. You call .Result() or .Err() to unpack it. This design separates command execution from response parsing. It also lets the library batch commands or handle pipeline responses without changing your call site. The compiler rejects the program with cannot use x (type *redis.Cmd) as type string in assignment if you try to assign the command wrapper directly to a variable. Always call .Result() or .Err().
A realistic HTTP handler with cache fallback
Real applications rarely store plain strings. You serialize structs to JSON, handle cache misses gracefully, and wrap the logic in a reusable function. Here is the cache lookup function.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
// User represents a simplified profile record.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// FetchUser checks Redis first, falls back to a simulated DB, then caches.
func FetchUser(ctx context.Context, rdb *redis.Client, id int) (*User, error) {
// Cache key follows a predictable naming convention for easy debugging.
cacheKey := fmt.Sprintf("user:%d", id)
// Try to read from Redis. .Bytes() handles nil and string conversion.
data, err := rdb.Get(ctx, cacheKey).Bytes()
if err != nil && err != redis.Nil {
// Network or server error. Return it immediately.
return nil, fmt.Errorf("redis get: %w", err)
}
// redis.Nil means the key does not exist. Proceed to the database.
if err == redis.Nil {
// Simulate a slow database query.
user := &User{ID: id, Name: "Alice", Email: "alice@example.com"}
// Marshal the struct to JSON for storage.
jsonData, marshalErr := json.Marshal(user)
if marshalErr != nil {
return nil, fmt.Errorf("json marshal: %w", marshalErr)
}
// Write back to cache with a 10-minute expiration.
if setErr := rdb.Set(ctx, cacheKey, jsonData, 10*time.Minute).Err(); setErr != nil {
// Log the cache write failure but still return the DB data.
log.Printf("failed to cache user %d: %v", id, setErr)
}
return user, nil
}
// Unmarshal the cached JSON back into a struct.
var user User
if unmarshalErr := json.Unmarshal(data, &user); unmarshalErr != nil {
return nil, fmt.Errorf("json unmarshal: %w", unmarshalErr)
}
return &user, nil
}
The function handles three paths. A cache hit returns the deserialized struct immediately. A cache miss queries the database, serializes the result, writes it to Redis, and returns it. A network error aborts the request. Notice the error wrapping with %w. It preserves the error chain for logging while keeping the top-level message clean. The cache write failure is logged but not returned. Failing to update the cache should not break the user request. The database is the source of truth. The cache is an optimization.
Here is how an HTTP handler uses the function.
package main
import (
"encoding/json"
"net/http"
"strconv"
"github.com/redis/go-redis/v9"
)
// HandlerFunc demonstrates the cache lookup in an HTTP context.
func HandlerFunc(rdb *redis.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Extract the user ID from the query string.
idStr := r.URL.Query().Get("id")
id, parseErr := strconv.Atoi(idStr)
if parseErr != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
// Pass the request context so cancellation propagates to Redis.
user, err := FetchUser(r.Context(), rdb, id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Set the content type and write the JSON response.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
}
The handler extracts the ID, passes r.Context() to the cache function, and writes the response. The request context carries the deadline and cancellation signal from the HTTP server. If the client disconnects, the context cancels, and the Redis client aborts the pending command. This prevents goroutine leaks and wasted database queries. The receiver name convention applies here too. Use short names like h or srv for handlers, not this or self. Go favors brevity over ceremony.
Where things go sideways
Redis caching introduces specific failure modes. The most common is the cache stampede. Multiple concurrent requests miss the cache at the same time. They all hit the database simultaneously. The database gets hammered. The fix is a mutex per key, a short TTL on the first miss, or a library like golang.org/x/sync/singleflight to deduplicate in-flight requests.
Serialization errors happen when you change a struct field but forget to update the cache key version. Old JSON stays in Redis. json.Unmarshal fails or silently drops new fields. The compiler rejects the program with cannot use x (type string) as type User in assignment if you try to assign raw bytes directly to a struct. Always unmarshal into a typed variable. Version your cache keys when the schema changes. Use user:v2:1 instead of user:1.
Connection pool exhaustion occurs when MaxConns is too low or requests hold connections too long. The client blocks waiting for an available socket. You see timeouts in your logs. Increase MaxConns or add ConnMaxIdleTime to recycle stale connections. Monitor pool metrics with rdb.PoolStats().
Forgetting to handle redis.Nil is a runtime logic bug. The client returns it when a key is missing. If you treat it like a network error, your application crashes on every cache miss. Check for redis.Nil explicitly before returning the error.
Goroutine leaks happen when you spawn a background goroutine to refresh the cache but never cancel its context. The goroutine waits on a channel or timer that never fires. Always pass a derived context with a deadline or use context.WithCancel and call the cancel function when the parent request ends. The worst goroutine bug is the one that never logs.
TTL management requires discipline. Set expiration times on every Set call. Redis does not enforce TTLs by default. Keys live forever unless you specify a duration. Use jitter on expiration times to prevent thundering herds when multiple keys expire simultaneously. Add a random offset of a few seconds to your base TTL.
Choosing your cache strategy
Use Redis caching when your read-to-write ratio exceeds ten to one and the data tolerates eventual consistency. Use a local in-memory map with sync.RWMutex when you need microsecond latency and can afford higher memory usage per process. Use database query caching or materialized views when the dataset is small enough to fit in the database buffer pool. Use no cache when data changes frequently, consistency is strict, or the query is already under five milliseconds. Cache the result, not the query.
Caching is a trade-off between speed and complexity. Measure before you optimize.