Your API is running smoothly until traffic spikes and database queries become the bottleneck. Caching in Go services isn't about storing everything—it's about understanding what to cache, where to cache it, and how to keep cached data fresh. This practical guide walks you through in-memory caches, distributed caching with Redis, request deduplication, and invalidation strategies that actually work in production.
Why caching matters in production
Before diving into implementation, let's be clear about the problem we're solving. In ride-hailing, travel, and e-commerce systems, certain data gets hammered repeatedly: user profiles, pricing rules, inventory counts, feature flags. Every database roundtrip costs milliseconds. Multiply that by thousands of concurrent requests and you've burned through your SLA.
Caching isn't free—it trades consistency for speed. The trick is knowing which tradeoff makes sense for each piece of data. User session data? Cache it aggressively. Real-time inventory? Be careful. The strategies we'll cover help you make that choice clear and measurable.
In-memory caches: local, fast, simple
An in-memory cache lives inside your service process. No network hop, no serialization overhead. For frequently accessed data that doesn't need coordination across instances, this is often your first choice.
You have two paths: build a simple map-based cache with a mutex and TTL, or reach for a library like Ristretto. For many teams, starting simple is the right call.
The mutex-and-map approach
Here's a minimal but functional cache:
package cache
import (
"sync"
"time"
)
type entry struct {
value interface{}
expiration time.Time
}
type SimpleCache struct {
mu sync.RWMutex
items map[string]entry
}
func NewSimpleCache() *SimpleCache {
return &SimpleCache{
items: make(map[string]entry),
}
}
func (c *SimpleCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry{
value: value,
expiration: time.Now().Add(ttl),
}
}
func (c *SimpleCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, exists := c.items[key]
if !exists {
return nil, false
}
if time.Now().After(item.expiration) {
return nil, false
}
return item.value, true
}
func (c *SimpleCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
This works fine for moderate scale. The RWMutex lets concurrent reads proceed without blocking. TTL is checked on access—no background cleanup thread needed yet. For a few thousand cached items with moderate traffic, this is sufficient.
When to use Ristretto
As cache size grows or contention becomes visible in profiles, Ristretto is worth the dependency. It uses a segmented design to reduce lock contention and offers built-in eviction policies:
package cache
import "github.com/dgryski/go-ristretto"
cache, err := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // number of keys you expect
MaxCost: 1 << 30, // max memory: 1GB
BufferItems: 64,
})
if err != nil {
panic(err)
}
// Set with cost (weight)
cache.SetWithTTL("user:123", userData, 1, 5*time.Minute)
// Get
if val, found := cache.Get("user:123"); found {
user := val.(*User)
// use user
}
Ristretto handles eviction automatically when you hit MaxCost, using a TinyLFU algorithm. It's thread-safe without global locks. For services handling thousands of requests per second, this overhead pays for itself quickly.
Distributed caches: Redis for cross-instance sharing
In-memory caches are local to each instance. When you scale to multiple servers, an in-memory cache on server A won't help requests hitting server B. That's where Redis comes in.
Redis sits outside your service, shared across all instances. It's slower than local memory (network latency), but still orders of magnitude faster than a database query. Use it for data that needs coordination or that you want to share across a fleet.
Basic Redis caching pattern
Here's a clean wrapper around go-redis:
package cache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
type RedisCache struct {
client *redis.Client
}
func NewRedisCache(addr string) *RedisCache {
return &RedisCache{
client: redis.NewClient(&redis.Options{
Addr: addr,
}),
}
}
func (c *RedisCache) Get(ctx context.Context, key string, dest interface{}) error {
data, err := c.client.Get(ctx, key).Result()
if err == redis.Nil {
return nil // cache miss
}
if err != nil {
return err
}
return json.Unmarshal([]byte(data), dest)
}
func (c *RedisCache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return c.client.Set(ctx, key, data, ttl).Err()
}
func (c *RedisCache) Delete(ctx context.Context, key string) error {
return c.client.Del(ctx, key).Err()
}
Now in your handler, you check Redis before querying the database:
func (s *Service) GetUser(ctx context.Context, userID string) (*User, error) {
var user User
// Try cache first
if err := s.cache.Get(ctx, "user:"+userID, &user); err == nil {
return &user, nil // cache hit
}
// Cache miss or error: query database
user, err := s.db.GetUser(ctx, userID)
if err != nil {
return nil, err
}
// Populate cache
_ = s.cache.Set(ctx, "user:"+userID, user, 10*time.Minute)
return user, nil
}
This pattern is solid: fast path for cache hits, fallback to the database on miss. Errors reading Redis don't block the request—we treat them as cache misses and fetch the data fresh.
Collapse concurrent requests with singleflight
Imagine a cache key expires and fifty requests arrive in the same millisecond asking for it. Without coordination, you'll execute fifty database queries. This is the cache stampede problem.
Go's sync/singleflight package solves it elegantly. When multiple goroutines call the same key simultaneously, only the first executes the work. The rest wait and share the result:
package service
import (
"context"
"sync"
"golang.org/x/sync/singleflight"
)
type UserService struct {
db Database
cache Cache
sfg singleflight.Group
}
func (s *UserService) GetUser(ctx context.Context, userID string) (*User, error) {
cacheKey := "user:" + userID
// Try cache first
var user User
if err := s.cache.Get(ctx, cacheKey, &user); err == nil {
return &user, nil
}
// Collapse concurrent requests
result, err, _ := s.sfg.Do(cacheKey, func() (interface{}, error) {
// Double-check cache in case another goroutine just filled it
if err := s.cache.Get(ctx, cacheKey, &user); err == nil {
return &user, nil
}
// Hit the database
dbUser, err := s.db.GetUser(ctx, userID)
if err != nil {
return nil, err
}
// Cache for next time
_ = s.cache.Set(ctx, cacheKey, dbUser, 10*time.Minute)
return dbUser, nil
})
if err != nil {
return nil, err
}
return result.(*User), nil
}
Singleflight is stateless—it only exists for the lifetime of that call group. Use a module-level or service-level Group for keys you expect to collide. The pattern is powerful: fifty requests become one database query, and all fifty get the same fresh data.
Invalidation: keeping cached data fresh
Caching is worthless if stale data breaks your service. Invalidation is the hard part. Two main strategies emerge: time-based (TTL) and event-based (delete on write).
Time-based invalidation with TTL
Set a reasonable TTL on every cache key. For user profiles, ten minutes is often right. For pricing data, maybe five minutes. For session tokens, match your session timeout.
TTL is simple and forgiving—stale data eventually disappears. The tradeoff: you might serve stale data for up to the TTL duration. That's acceptable for many reads; it's not acceptable for recent writes.
Event-based invalidation
When you write data, delete the cache key immediately. This keeps consistency tight:
func (s *Service) UpdateUser(ctx context.Context, userID string, updates *UserUpdate) error {
// Update database first
err := s.db.UpdateUser(ctx, userID, updates)
if err != nil {
return err
}
// Invalidate cache immediately
_ = s.cache.Delete(ctx, "user:"+userID)
return nil
}
The next read will miss the cache and fetch fresh data. Combined with TTL (as a safety net), this gives you strong consistency: every write is immediately visible to all readers.
For distributed systems, publish cache invalidation events to your message broker (Kafka, RabbitMQ) so all instances clear the same keys:
func (s *Service) UpdateUser(ctx context.Context, userID string, updates *UserUpdate) error {
err := s.db.UpdateUser(ctx, userID, updates)
if err != nil {
return err
}
// Local invalidation
_ = s.cache.Delete(ctx, "user:"+userID)
// Broadcast to all instances
event := UserUpdatedEvent{UserID: userID, UpdatedAt: time.Now()}
return s.events.Publish(ctx, "user.updated", event)
}
Other instances subscribe and invalidate their local caches too. This scales well and keeps latency low on writes (you don't wait for remote cache clears).
Common mistakes
- Caching without TTL: A key that never expires is a memory leak. Always set TTL, even if it's generous. Ristretto and Redis both enforce this; your simple map should too.
- Forgetting to invalidate on write: You update a database row but don't clear the cache. Readers see stale data indefinitely. Invalidate on every write, or use strict TTLs as a backstop.
- Caching errors: If a query fails, don't cache the error for long. Cache it for 30 seconds to shield against thundering queries, then let clients retry. Otherwise a transient database outage becomes a cascade.
- Over-caching low-latency data: If your database query takes 5ms and Redis adds 10ms (round-trip), caching makes things worse. Profile first. Cache only hot paths that show real latency improvement.
- Ignoring stampedes in development: Cache stampedes don't appear with small traffic. Use load testing or singleflight from the start to avoid surprising production behavior.
Key takeaways
- Start with a simple in-memory cache for single-instance services; graduate to Ristretto or Redis as scale demands.
- Use singleflight to collapse concurrent requests for the same key and avoid cache stampedes.
- Always set TTL on cached data—no exceptions. It's your safety net against staleness and memory leaks.
- Invalidate on write: delete cache keys immediately after updates to keep consistency tight. Use events to broadcast invalidation across instances.
- Profile before and after caching. If your database is fast enough, caching adds latency. Target only genuinely hot paths.
- Treat cache misses and Redis errors the same way: fall back to the database. Don't let cache problems break your service.






Comments
Loading comments…