Skip to content

When Should You Use Cache-Aside Pattern in Microservices? Best Practices and Pitfalls

Problem

I was designing a caching strategy for our microservices architecture and got stuck on one question: When is cache-aside pattern actually worth using?

I had heard that cache-aside is the “standard” caching pattern, but I wasn’t sure if it was right for our specific workload. Our product catalog had high read volume, but our inventory system changed constantly. Should I use cache-aside for both? Or should I use different patterns?

After some trial and error, I learned that cache-aside isn’t a universal solution. It works great in some scenarios and creates headaches in others. Here’s what I discovered.

What is Cache-Aside Pattern?

Cache-aside (also called lazy loading) means:

  1. Application checks cache first
  2. If data not in cache (cache miss), fetch from database
  3. Store fetched data in cache for future requests
  4. Next request hits cache directly
cache-aside-basic.js
async function getProduct(productId) {
// Step 1: Check cache
const cached = await redis.get(`product:${productId}`);
if (cached) {
return JSON.parse(cached); // Cache hit
}
// Step 2: Cache miss - fetch from DB
const product = await db.queryProduct(productId);
// Step 3: Store in cache
await redis.set(`product:${productId}`, JSON.stringify(product), 'EX', 3600);
return product;
}

The key characteristic: cache is filled on demand, not proactively.

Decision Flowchart

Before diving into details, here’s a quick decision guide:

Should I Use Cache-Aside?
┌─────────────────────────────────────┐
│ Is your workload read-heavy? │
│ (many repeated queries) │
└─────────────────┬───────────────────┘
YES ──────┼────── NO
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Continue check │ │ Avoid cache-aside│
│ │ │ (write-heavy) │
└─────────┬───────┘ └─────────────────┘
┌─────────────────────────────────────┐
│ Are query parameters unpredictable? │
│ (can't predict what users will ask) │
└─────────────────┬───────────────────┘
YES ──────┼────── NO
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ USE CACHE-ASIDE │ │ Consider cache │
│ │ │ prefetching │
└─────────────────┘ └─────────────────┘

When to Use Cache-Aside

After testing different scenarios, I found cache-aside works best when:

1. High Read Volume with Repeated Queries

Our product catalog API gets 1000+ reads per minute for the same products. Cache-aside reduced database load by 80%.

Read Pattern Analysis
Before Cache: DB queries: 1000/min
After Cache-aside: DB queries: 200/min (80% reduction)
Cache hits: 800/min

2. Unpredictable Query Parameters

Users search products with random filters: category:electronics, brand:sony, price<100. I can’t predict what combinations they’ll use, so pre-loading everything would waste memory.

Cache-aside fills cache on demand - only what users actually request.

3. Storage Cost Constraints

In our cloud environment, Redis memory costs money. Pre-caching all products would require 4GB. With cache-aside, we only cache frequently accessed items - about 500MB.

4. Data Changes Infrequently

Product details (name, description, images) change maybe once per week. Perfect for caching with reasonable TTL.

When to Avoid Cache-Aside

I tried cache-aside on our inventory system and it failed badly. Here’s why:

1. Write-Heavy Workloads

Inventory updates happen constantly. Every purchase, restock, or adjustment changes counts. Caching this data means:

  • Cache invalidation complexity
  • Stale data risk
  • More work than benefit
bad-cache-inventory.js
// WRONG: Caching rapidly changing data
async function getInventory(productId) {
const cached = await redis.get(`inventory:${productId}`);
if (cached) return JSON.parse(cached);
const inventory = await db.queryInventory(productId);
await redis.set(`inventory:${productId}`, JSON.stringify(inventory), 'EX', 60);
return inventory;
}
// After 60 seconds, cache shows "100 units available"
// But actual inventory is "50 units" - users get wrong info

2. Real-Time Data Requirements

Our stock alerts need current counts. A 60-second stale cache would send false alerts or miss critical lows.

3. First-Request Latency Critical

Cache-aside’s first request always hits database (cold cache problem). For our checkout flow, every millisecond counts. Cache prefetching works better here.

Best Practices I Learned

1. Set Appropriate TTL Per Data Type

Different data has different freshness needs:

ttl-config.js
const TTL_CONFIG = {
product_catalog: 3600, // 1 hour - products change infrequently
user_session: 1800, // 30 minutes - session data
real_time_inventory: 60, // 1 minute - if you must cache inventory
static_config: 86400, // 24 hours - rarely changes
};
async function setCacheWithTTL(key, data, dataType) {
const ttl = TTL_CONFIG[dataType] || 300; // default 5 min
await redis.set(key, JSON.stringify(data), 'EX', ttl);
}

2. Invalidate Cache on Writes

This is the most common mistake I made initially. After updating a product, users still saw old cached data.

cache-invalidation.js
async function updateProduct(productId, newData) {
// 1. Update database FIRST
await db.updateProduct(productId, newData);
// 2. Invalidate cache AFTER
await redis.del(`product:${productId}`);
// Alternative: Update cache directly
// await redis.set(`product:${productId}`, JSON.stringify(newData), 'EX', 3600);
}

The sequence matters: update DB first, then invalidate. If you invalidate first and the DB update fails, you lose both cache and correct DB data.

3. Monitor Cache Hit Ratio

I track hit ratio weekly. If it drops below 70%, I adjust TTL or reconsider what I’m caching.

Monitoring Metrics
Week 1: Hit ratio 85%, Avg latency 50ms (good)
Week 2: Hit ratio 65%, Avg latency 120ms (investigate)
- Found: New product category not cached enough
- Fix: Increase TTL for popular categories
Week 3: Hit ratio 78%, Avg latency 60ms (improved)

4. Hash Query Parameters Consistently

Complex queries need consistent cache keys:

cache-key-hash.js
// BAD: Different keys for same query
await redis.get('products:category=electronics');
await redis.get('products:electronics'); // Same query, different key!
// GOOD: Consistent hashing
function buildCacheKey(params) {
const sorted = Object.keys(params).sort().map(k => `${k}=${params[k]}`);
return `products:${sorted.join('&')}`;
}
// Always generates: products:brand=sony&category=electronics&price<100

Common Pitfalls

Pitfall 1: Not Invalidating on Writes

Symptom: Users see stale data after updates Solution: Always invalidate after write operations

Pitfall 2: TTL Too Long

Symptom: Data becomes outdated Example: Set 24-hour TTL on pricing, prices update, customers see wrong prices Solution: Match TTL to data change frequency

Pitfall 3: TTL Too Short

Symptom: Cache becomes useless, high miss rate Example: 5-second TTL on product catalog - most requests miss cache Solution: Analyze read patterns, set TTL based on query frequency

Pitfall 4: Over-Caching

Symptom: Redis memory full, rarely-used data cached Solution: Track what’s accessed, only cache hot data

Pitfall 5: Ignoring Cold Cache Problem

Symptom: First requests always slow after deployment Solution: Consider cache warm-up or hybrid with prefetching

Microservices Considerations

In microservices, each service can choose its own caching strategy:

Service-Level Cache Strategy
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Product │ │ Inventory │ │ Payment │
│ Service │ │ Service │ │ Service │
│ │ │ │ │ │
│ Cache-aside │ │ NO cache │ │ Cache-aside │
│ TTL: 1 hour │ │ (real-time) │ │ TTL: 5 min │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└───────────────────┼───────────────────┘
┌───────▼───────┐
│ Redis │
│ Cluster │
└───────────────┘

API Gateway can add another caching layer for common API responses, reducing calls to downstream services.

Summary

Cache-aside pattern works well when you have:

  • Read-heavy workloads with repeated queries
  • Unpredictable query parameters
  • Storage cost constraints
  • Data that changes infrequently

Avoid it when:

  • Data changes rapidly (write-heavy)
  • Real-time freshness is critical
  • First-request latency matters
  • Cache invalidation complexity outweighs benefits

Set appropriate TTL per data type, invalidate on writes, and monitor hit ratios. In microservices, let each service choose its strategy based on its specific workload characteristics.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments