Redis Caching Patterns for High-Performance Microservices
Backend Developer | Golang & Python I enjoy building reliable APIs, distributed systems, and automation tools. Writing here about backend engineering, system design, and real-world dev experiences.
Introduction
I once built an order service that hit the database for every single request. Worked fine during development. Then we launched.
Load test showed P99 latency at 800ms. Database CPU spiked to 90%. Under real traffic, the service would melt down.
So I added Redis caching. P99 latency dropped to 50ms. Database load went down 80%. Same hardware, 10x better performance.
Caching seems simple until you start asking questions: What do I cache? For how long? How do I invalidate? What about consistency?
In this post, I'll show you the caching patterns I use in production microservices. These aren't academic—these are the patterns that keep services running at scale.
Why Redis?
There are other caching options (Memcached, in-memory maps, etc.), but Redis wins for microservices:
Fast: Sub-millisecond latency
Rich data structures: Strings, hashes, lists, sets, sorted sets
TTL support: Auto-expire old data
Persistence: Can survive restarts
Clustering: Scales horizontally
Pub/Sub: Bonus feature for cache invalidation
The Cache-Aside Pattern
This is the most common pattern. Application checks cache first, falls back to database on miss.
type OrderRepository struct {
db *mongo.Collection
cache *redis.Client
}
func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
// 1. Try cache first
cacheKey := fmt.Sprintf("order:%s", orderID)
cached, err := r.cache.Get(ctx, cacheKey).Result()
if err == nil {
// Cache hit
var order Order
if err := json.Unmarshal([]byte(cached), &order); err == nil {
return &order, nil
}
}
// 2. Cache miss - query database
var order Order
err = r.db.FindOne(ctx, bson.M{"_id": orderID}).Decode(&order)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, ErrOrderNotFound
}
return nil, err
}
// 3. Store in cache for next time
orderJSON, _ := json.Marshal(order)
r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute)
return &order, nil
}
Pros:
Simple to implement
Cache only what's requested
Failure-tolerant (cache down = slower, not broken)
Cons:
First request always misses (cold start)
Extra latency on cache miss
Cache Invalidation Strategies
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Strategy 1: Time-Based (TTL)
Set an expiration time. After that, data is evicted.
// Cache for 5 minutes
r.cache.Set(ctx, key, value, 5*time.Minute)
When to use:
Data changes infrequently
Stale data is acceptable for short periods
Simple, no coordination needed
Example: Product catalog (changes once a day, 1-hour TTL is fine)
Strategy 2: Write-Through
Update cache whenever you update the database.
func (r *OrderRepository) Update(ctx context.Context, order *Order) error {
// 1. Update database
filter := bson.M{"_id": order.ID, "version": order.Version - 1}
update := bson.M{"$set": order}
result, err := r.db.UpdateOne(ctx, filter, update)
if err != nil {
return err
}
if result.MatchedCount == 0 {
return ErrOptimisticLockFailed
}
// 2. Update cache
cacheKey := fmt.Sprintf("order:%s", order.ID)
orderJSON, _ := json.Marshal(order)
r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute)
return nil
}
Pros:
Cache always has latest data
No stale data issues
Cons:
More complex (two writes)
What if cache write fails?
Strategy 3: Write-Invalidate
Delete from cache when you update the database.
func (r *OrderRepository) Update(ctx context.Context, order *Order) error {
// 1. Update database
if err := r.updateInDB(ctx, order); err != nil {
return err
}
// 2. Invalidate cache
cacheKey := fmt.Sprintf("order:%s", order.ID)
r.cache.Del(ctx, cacheKey)
// Next read will cache fresh data from DB
return nil
}
Pros:
Simpler than write-through
No stale data
Cons:
- First read after update is a cache miss
This is my preferred approach. Simple and effective.
What to Cache (and What Not to Cache)
Not everything should be cached. Here's my decision tree:
✅ CACHE THESE:
1. Hot data (frequently accessed):
// User profiles, popular products, etc.
r.cache.Set(ctx, key, value, 10*time.Minute)
2. Expensive queries:
// Complex aggregations, joins, etc.
// Cache longer since they're expensive to recompute
r.cache.Set(ctx, key, value, 30*time.Minute)
3. External API calls:
// Third-party APIs (payment gateways, shipping APIs)
// Cache to reduce costs and improve reliability
r.cache.Set(ctx, key, value, 15*time.Minute)
4. Session data:
// User sessions, shopping carts
r.cache.Set(ctx, "session:"+sessionID, data, 30*time.Minute)
❌ DON'T CACHE THESE:
1. Data that changes frequently:
// Stock prices, live sports scores
// By the time you cache it, it's stale
2. User-specific data at scale:
// If you have millions of users, caching per-user data blows up memory
// Cache only for active users
3. Large objects:
// Images, videos, large documents
// Use CDN or object storage instead
4. Data that must be consistent:
// Financial transactions, inventory counts
// Read from database for critical operations
Caching Patterns by Use Case
Pattern 1: List Caching
Cache expensive list queries:
func (r *OrderRepository) FindByCustomerID(ctx context.Context, customerID string) ([]*Order, error) {
cacheKey := fmt.Sprintf("orders:customer:%s", customerID)
// Try cache
cached, err := r.cache.Get(ctx, cacheKey).Result()
if err == nil {
var orders []*Order
json.Unmarshal([]byte(cached), &orders)
return orders, nil
}
// Query database
cursor, err := r.db.Find(ctx, bson.M{"customerId": customerID})
if err != nil {
return nil, err
}
var orders []*Order
cursor.All(ctx, &orders)
// Cache for 2 minutes (shorter TTL for user-specific data)
ordersJSON, _ := json.Marshal(orders)
r.cache.Set(ctx, cacheKey, ordersJSON, 2*time.Minute)
return orders, nil
}
Invalidation strategy:
func (r *OrderRepository) Create(ctx context.Context, order *Order) error {
if err := r.db.InsertOne(ctx, order); err != nil {
return err
}
// Invalidate customer's order list
cacheKey := fmt.Sprintf("orders:customer:%s", order.CustomerID)
r.cache.Del(ctx, cacheKey)
return nil
}
Pattern 2: Computed Values
Cache expensive calculations:
func (s *AnalyticsService) GetDailySales(ctx context.Context, date time.Time) (float64, error) {
cacheKey := fmt.Sprintf("sales:daily:%s", date.Format("2006-01-02"))
// Try cache
cached, err := s.cache.Get(ctx, cacheKey).Result()
if err == nil {
sales, _ := strconv.ParseFloat(cached, 64)
return sales, nil
}
// Expensive aggregation query
pipeline := []bson.M{
{"$match": bson.M{"date": date}},
{"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
}
var result []struct{ Total float64 }
s.db.Aggregate(ctx, pipeline).All(ctx, &result)
total := 0.0
if len(result) > 0 {
total = result[0].Total
}
// Cache for 24 hours (past data doesn't change)
s.cache.Set(ctx, cacheKey, fmt.Sprintf("%f", total), 24*time.Hour)
return total, nil
}
Pattern 3: Negative Caching
Cache "not found" results to prevent repeated DB hits:
func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
cacheKey := fmt.Sprintf("order:%s", orderID)
cached, err := r.cache.Get(ctx, cacheKey).Result()
if err == nil {
if cached == "NOT_FOUND" {
return nil, ErrOrderNotFound // cached negative result
}
var order Order
json.Unmarshal([]byte(cached), &order)
return &order, nil
}
// Query database
var order Order
err = r.db.FindOne(ctx, bson.M{"_id": orderID}).Decode(&order)
if err == mongo.ErrNoDocuments {
// Cache the "not found" result for 1 minute
r.cache.Set(ctx, cacheKey, "NOT_FOUND", 1*time.Minute)
return nil, ErrOrderNotFound
}
if err != nil {
return nil, err
}
// Cache the order
orderJSON, _ := json.Marshal(order)
r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute)
return &order, nil
}
This prevents attackers from hammering your DB with invalid IDs.
Cache Warming
Don't wait for users to populate the cache. Pre-load hot data:
func (s *CacheWarmingService) WarmCache() error {
// Get top 100 most popular products
products, err := s.db.Find(ctx, bson.M{}).Sort("-views").Limit(100)
if err != nil {
return err
}
// Pre-cache them
for _, product := range products {
cacheKey := fmt.Sprintf("product:%s", product.ID)
productJSON, _ := json.Marshal(product)
s.cache.Set(ctx, cacheKey, productJSON, 30*time.Minute)
}
logger.Info("cache warmed", zap.Int("products", len(products)))
return nil
}
// Run on startup and periodically
func main() {
service := NewCacheWarmingService()
service.WarmCache() // on startup
// Refresh every hour
ticker := time.NewTicker(1 * time.Hour)
go func() {
for range ticker.C {
service.WarmCache()
}
}()
}
Handling Cache Failures
Redis goes down. Now what?
Strategy: Graceful Degradation
func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
// Try cache, but don't fail if it's down
cacheKey := fmt.Sprintf("order:%s", orderID)
cached, err := r.cache.Get(ctx, cacheKey).Result()
if err == nil {
var order Order
if err := json.Unmarshal([]byte(cached), &order); err == nil {
return &order, nil
}
} else if err != redis.Nil {
// Cache is down (not just a miss)
logger.Warn("cache unavailable", zap.Error(err))
}
// Fall back to database
var order Order
err = r.db.FindOne(ctx, bson.M{"_id": orderID}).Decode(&order)
if err != nil {
return nil, err
}
// Try to cache, but don't fail if it errors
orderJSON, _ := json.Marshal(order)
if err := r.cache.Set(ctx, cacheKey, orderJSON, 5*time.Minute).Err(); err != nil {
logger.Warn("failed to cache order", zap.Error(err))
}
return &order, nil
}
Key: Cache failures don't break your service. They just make it slower.
Monitoring Cache Performance
Track these metrics:
var (
cacheHits = promauto.NewCounter(prometheus.CounterOpts{
Name: "cache_hits_total",
Help: "Total cache hits",
})
cacheMisses = promauto.NewCounter(prometheus.CounterOpts{
Name: "cache_misses_total",
Help: "Total cache misses",
})
cacheErrors = promauto.NewCounter(prometheus.CounterOpts{
Name: "cache_errors_total",
Help: "Total cache errors",
})
)
func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*Order, error) {
cached, err := r.cache.Get(ctx, cacheKey).Result()
if err == nil {
cacheHits.Inc()
// ...
} else if err == redis.Nil {
cacheMisses.Inc()
// ...
} else {
cacheErrors.Inc()
// ...
}
}
Track:
Hit rate (hits / (hits + misses)) - aim for >80%
Miss rate - sudden spikes indicate cache eviction
Error rate - indicates Redis issues
Latency - cache should be <5ms p99
Common Caching Mistakes
Caching everything: Wastes memory, doesn't improve performance.
TTL too long: Stale data everywhere.
TTL too short: Cache never hits, constant DB load.
Not handling cache failures: Service breaks when Redis is down.
Ignoring memory limits: Redis runs out of memory, starts evicting.
Caching large objects: Serialization overhead kills performance.
Wrapping Up
Caching is one of the highest-leverage optimizations you can make. A well-placed cache can 10x your throughput and slash latency.
Start simple:
Identify your slowest queries
Add cache-aside pattern with TTL
Monitor hit rates
Tune TTLs based on data
Once you see the benefits, expand to more sophisticated patterns.
Next up: Optimistic locking and handling race conditions in distributed systems.
Questions? Drop a comment. Caching has a lot of nuances—happy to discuss specific use cases.
Resources
Thanks for reading! This is part of my microservices series. Follow along for more posts on concurrency, deployment, and production patterns.