When to use cache and when not to

When to use cache and when not to

Table of Contents

There is a production bug that I still remember vividly: user A cancels an order, but the app still displays “delivering” for the next 10 minutes. Support receives 30 tickets in one morning. The reason: cache TTL is 10 minutes, but no one invalidates it when the order status changes.

That was the first time I understood: cache is not always your friend. Used incorrectly, it is the number one enemy of data consistency.

This article does not discuss LRU, LFU, or eviction policy. I will focus on the most important question that few people take the time to think about: when to cache, when not to, and how to avoid regretting it a month later.

Cache Strategy

When to Use Cache

1. Read-Heavy, Write-Light - “Pre-Cooked” Data

User profiles, product catalogs, system configurations - rarely changed but frequently accessed. This type of data is ideal for caching.

// Pattern: cache-aside
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    // Check cache first
    if data, err := s.cache.Get(ctx, "user:"+id); err == nil {
        return decodeUser(data), nil
    }

    // Cache miss → query DB
    user, err := s.db.GetUser(ctx, id)
    if err != nil {
        return nil, err
    }

    // Set cache for next time
    s.cache.Set(ctx, "user:"+id, encodeUser(user), 10*time.Minute)
    return user, nil
}

Cache-aside is the simplest pattern and the one I use the most. Advantages: easy-to-understand code, no magic. Disadvantages: the first cache miss is still slow.

2. Hot Spots - 20% of Data Receives 80% of Requests

Flash sales, viral articles, prominent merchants. When 80% of traffic only hits 20% of the data, caching immediately reduces the load on the DB significantly. This is the largest quick win in terms of ROI in performance optimization.

// Pattern: cache top-N hot items
func (s *Service) GetHotMerchants(ctx context.Context) ([]Merchant, error) {
    if data, err := s.cache.Get(ctx, "merchants:hot"); err == nil {
        return decodeMerchants(data), nil
    }

    merchants, err := s.db.TopByOrders(ctx, 50) // top 50 merchants
    if err != nil {
        return nil, err
    }

    s.cache.Set(ctx, "merchants:hot", encodeMerchants(merchants), 5*time.Minute)
    return merchants, nil
}

3. Accepting Stale Data for a Few Seconds

View counts, like counts, rankings - a 2-3 second discrepancy is not noticeable to users. But if COUNT(*) is executed every time the page is refreshed, the DB will be overwhelmed.

4. Expensive Queries - Aggregation, Reports, Dashboards

Queries that take 5-10 seconds to run should have their results cached and refreshed periodically:

func (s *Service) GetDashboard(ctx context.Context) (*Dashboard, error) {
    key := "dashboard:daily"
    if data, err := s.cache.Get(ctx, key); err == nil {
        return decodeDashboard(data), nil
    }

    dash, err := s.computeDashboard(ctx) // expensive query taking 8 seconds
    if err != nil {
        return nil, err
    }

    s.cache.Set(ctx, key, encodeDashboard(dash), 15*time.Minute)
    return dash, nil
}

When NOT to Cache

1. Highly Volatile Data - “Cache is a Disaster”

Account balances, real-time inventory, order status. Caching here → users see incorrect balances, buy out-of-stock items. Read directly from the DB or use event-driven updates.

2. High Cardinality - Key Space Explosion

// ❌ Wrong: cache key too detailed → millions of keys, hit rate near 0
cacheKey := fmt.Sprintf("products:c=%s&p_min=%d&p_max=%d&sort=%s&page=%d",
    category, minPrice, maxPrice, sortBy, page)
// ✅ Correct: cache at a higher level, filter/paginate at the app layer
cacheKey := "products:popular" // cache top 200, filter in memory

The more keys there are, the lower the hit rate, and caching becomes an overhead instead of an optimization.

3. Upstream is Already Fast - Don’t Optimize What’s Not Needed

DB queries return in 2ms with the correct index. Adding cache only creates complexity and adds a potential point of failure. I’ve seen teams cache responses from already fast endpoints - latency increases from 2ms to 3ms due to the Redis network hop.

4. No Invalidation Strategy

If you don’t know when and how to invalidate the cache when data changes - don’t cache. Displaying old data to users is worse than not caching at all.

Invalidation Strategy

StrategyUse CaseWarning
TTLData rarely changes, accepting stale data for a few minutesStale data within the TTL window
Write-throughNeed to sync immediately, few writesIncreased write latency
Event-drivenData consistency is the most importantMost complex to implement correctly

The write-through pattern code I used:

func (s *Service) UpdateProduct(ctx context.Context, p *Product) error {
    if err := s.db.Update(ctx, p); err != nil {
        return err
    }

    // Invalidate related cache keys
    s.cache.Delete(ctx, "product:"+p.ID)
    s.cache.Delete(ctx, "products:popular")
    s.cache.Delete(ctx, "products:category:"+p.CategoryID)
    return nil
}

Don’t forget to invalidate all cache keys containing old data. I once deleted product:123 but forgot products:popular → the list page still displayed the old price. This bug took 4 hours to fix.

Decision Rules

Criteria✅ Cache❌ No Cache
Read/Write ratioRead more than write 10x+Write more than read
Data freshnessAcceptable stale OKNeed real-time
Complexity benefitBenefit > maintenance costDB is fast enough
Invalidation planClear strategy“Leave it for later”

“Cache is like a thermos - super useful for best-sellers, but if left for too long, the coffee also turns sour.”

Bottom line

Cache is a double-edged sword. Used correctly: p99 latency decreases 10x. Used incorrectly: data inconsistency, the hardest-to-debug bug ever. Before caching anything, ask 3 questions: (1) How often does this data change? (2) Do I have an invalidation plan? (3) What is the actual benefit in milliseconds?

If you can’t answer all 3 - stick to the original, query the DB, and sleep better.

Have you ever encountered a “cache displaying old data” bug? Is there any effective invalidation pattern that I haven’t tried? Share your war story about cache with me - lessons from cache bugs are always the most valuable. 🍵

Share :

Related Posts

API Filtering: retrieving data like a coffee connoisseur

API Filtering: retrieving data like a coffee connoisseur

In my first year of working, I once wrote an endpoint GET /api/menus that returned… the entire menu. 200 items every time it was called. The JSON was 1.2MB heavy. The frontend only needed the name and price of 10 active dishes. I remember the first thing my lead said: “You’re sending the entire warehouse to someone who just needs to view the menu, aren’t you?”

Read More
Context in Go: passing data, deadline, cancel - use correctly or die of performance

Context in Go: passing data, deadline, cancel - use correctly or die of performance

When I first coded in Go, I encountered a strange production bug: after running for 3-4 days, the service suddenly consumed 8GB of RAM and caused an OOM error. I spent the entire Friday afternoon debugging and finally discovered the issue: a goroutine never ended because I forgot to pass the context to the gRPC call. Each request leaked a goroutine, and after 100K requests, the server was overwhelmed.

Read More