Skip to content

How to Implement Cache-Aside Pattern with Redis for Query Caching

The Problem

My e-commerce application was struggling with database performance during peak hours. Product search queries were taking 2-3 seconds, and the database CPU utilization hit 90% during flash sales. I had already optimized my SQL queries with proper indexes, but the sheer volume of read requests was overwhelming the database.

Performance Metrics Before Caching
Database queries per second: 500+
Average query latency: 2.5 seconds
Database CPU: 90%
Customer complaints: "Search is too slow"

I needed a caching solution. But which pattern should I use? After researching, I found several caching patterns:

Common Caching Patterns
1. Cache-Aside (Lazy Loading) - Application manages cache
2. Read-Through - Cache manages database lookups
3. Write-Through - Cache updates on write
4. Write-Behind - Async cache updates

For my use case, cache-aside (also called lazy loading) was the best fit. Let me explain why and how I implemented it.

Why Cache-Aside?

The cache-aside pattern gives the application full control over when data enters the cache. Unlike read-through caches where the cache itself manages database lookups, cache-aside populates the cache only on demand:

Cache-Aside Key Characteristics
- Cache populated on demand (cache miss triggers fill)
- Application controls what gets cached
- Cache hit returns data directly from Redis
- Cost-effective: only frequently requested data is cached

This pattern is ideal when:

  1. You need to query data frequently
  2. You want to fill the cache on demand
  3. You’re cost-conscious about cache storage
  4. Your data doesn’t change frequently

How Cache-Aside Works

Here’s the basic flow:

Cache-Aside Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Request with search params │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Hash params → │ │
│ │ Generate key │ │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ ┌─────────────────┐ │
│ │ Check Redis │────▶│ Cache Hit? │ │
│ └──────────────────┘ └─────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ │ │
│ YES │ │ NO │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ Return from │ │ Query database │ │
│ │ Redis │ │ │ │
│ └─────────────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Store in Redis │ │
│ │ with TTL │ │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Return result │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

The pattern has five steps:

  1. Hash query parameters to form a cache key
  2. Check Redis first for cached data
  3. Return immediately on cache hit
  4. Query database on cache miss
  5. Store result in Redis with TTL, then return

Implementation

Step 1: Generate Cache Key

The first step is creating a consistent cache key from query parameters. I use a hash function to ensure identical queries get identical keys:

cacheKeyGenerator.js
function generateCacheKey(searchParams) {
// Sort keys to ensure consistent ordering
const sortedParams = Object.keys(searchParams)
.sort()
.map(key => `${key}:${searchParams[key]}`)
.join('|');
// Hash to create fixed-length key
return `product_search:${hashString(sortedParams)}`;
}
function hashString(str) {
// Simple hash function (use crypto for production)
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(16);
}

I initially made a mistake here - I didn’t sort the keys. This caused different cache keys for the same query:

My Initial Mistake
Query: {category: "electronics", price: "100"}
Key 1: "product_search:category:electronics|price:100"
Query: {price: "100", category: "electronics"} // Same data, different order
Key 2: "product_search:price:100|category:electronics" // Different key!
Result: Two cache entries for the same query = wasted memory

Sorting the keys before hashing fixed this issue.

Step 2: Check Redis

Next, I check Redis for the cached result:

cacheAside.js
async function getCachedProducts(searchParams) {
const cacheKey = generateCacheKey(searchParams);
// Step 2: Check Redis first
const cachedResult = await redis.get(cacheKey);
if (cachedResult) {
// Cache hit - return immediately
console.log(`Cache HIT for key: ${cacheKey}`);
return JSON.parse(cachedResult);
}
// Cache miss - continue to database query
console.log(`Cache MISS for key: ${cacheKey}`);
return null;
}

Step 3: Query Database and Cache Result

On cache miss, I query the database and store the result:

cacheAsideWithDB.js
async function getProducts(searchParams) {
const cacheKey = generateCacheKey(searchParams);
// Check Redis
const cachedResult = await redis.get(cacheKey);
if (cachedResult) {
return JSON.parse(cachedResult);
}
// Cache miss - query database
const dbResult = await database.query(`
SELECT * FROM products
WHERE category = ? AND price <= ?
ORDER BY created_at DESC
LIMIT 20
`, [searchParams.category, searchParams.price]);
// Store in Redis with TTL (5 minutes for product search)
await redis.set(
cacheKey,
JSON.stringify(dbResult),
'EX',
300 // TTL in seconds
);
return dbResult;
}

Step 4: Set Appropriate TTL

Choosing the right TTL is crucial. I went through several iterations:

TTL Iteration Process
Attempt 1: TTL = 60 seconds
Result: Too many cache misses, database still overloaded
Attempt 2: TTL = 3600 seconds (1 hour)
Result: Stale data showing, customers complaining about outdated prices
Attempt 3: TTL = 300 seconds (5 minutes)
Result: Good balance - fresh enough data, significant load reduction

The TTL depends on your data freshness requirements:

TTL Guidelines by Data Type
- Product prices: 60-300 seconds (prices change frequently)
- Product descriptions: 3600-7200 seconds (rarely changes)
- Category lists: 86400 seconds (very stable)
- User sessions: 1800 seconds (security requirement)

Cache Invalidation

I forgot this initially and learned a painful lesson. When product data changes, the cache must be invalidated:

cacheInvalidation.js
async function updateProduct(productId, newData) {
// Update database
await database.update('products', productId, newData);
// Invalidate related cache entries
// Option 1: Delete specific keys (if you know them)
await redis.del(`product:${productId}`);
// Option 2: Use cache tags for grouped invalidation
// Delete all cache entries for this category
const categoryKeys = await redis.keys(`product_search:*category:${newData.category}*`);
if (categoryKeys.length > 0) {
await redis.del(categoryKeys);
}
}

Common Mistakes

I made several mistakes during implementation. Here are the pitfalls to avoid:

Mistake 1: Not Setting TTL

Wrong: No TTL
await redis.set(cacheKey, JSON.stringify(dbResult));
// Data stays forever - stale data problem!
Correct: Always set TTL
await redis.set(cacheKey, JSON.stringify(dbResult), 'EX', 300);

Mistake 2: TTL Too Short

TTL Too Short Problem
TTL = 10 seconds
Requests per second = 100
Cache hit rate = 10% (only 10 requests within TTL window)
Database load = 90 queries/second (almost same as no cache!)

Mistake 3: Hash Collision

Wrong: Simple concatenation
const key = `search:${params.category}${params.price}`;
// "search:electronics100" and "search:electro100nics" could collide
Correct: Proper hashing with delimiter
const key = `search:${hashString(sortedParams)}`;

Mistake 4: Forgetting Cache Invalidation

Stale Data Scenario
1. User searches for "iPhone 15" - cached with price $999
2. Admin updates price to $899
3. User searches again - sees cached $999 (wrong!)
4. Customer complaints: "Your prices are wrong"

Results After Implementation

After implementing cache-aside, my performance metrics improved dramatically:

Performance Metrics After Caching
Database queries per second: 50 (down from 500+)
Average query latency: 50ms (down from 2.5s)
Database CPU: 20% (down from 90%)
Cache hit rate: 90%
Customer satisfaction: Improved significantly

The cache hit rate of 90% means only 10% of requests hit the database. This is the power of cache-aside for read-heavy workloads.

When NOT to Use Cache-Aside

Cache-aside isn’t always the right choice:

Scenarios Where Cache-Aside May Not Fit
1. Write-heavy workloads: Cache invalidation overhead outweighs benefits
2. Real-time data: TTL can't keep data fresh enough
3. Complex queries with many variations: Too many cache keys, low hit rate
4. Memory constraints: Cache grows unbounded without limits

For these scenarios, consider:

  • Read-through/write-through patterns
  • Write-behind (async updates)
  • Time-series optimized caches

Summary

In this post, I explained how to implement the cache-aside pattern with Redis for query caching:

  1. Hash query parameters to generate consistent cache keys
  2. Check Redis first - return immediately on cache hit
  3. Query database on miss - store result with appropriate TTL
  4. Set TTL based on data freshness requirements
  5. Always invalidate cache when underlying data changes

The cache-aside pattern reduced my database load by 90% and query latency from 2.5 seconds to 50ms. For read-heavy workloads like product search, it’s an effective solution.

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