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.
Database queries per second: 500+Average query latency: 2.5 secondsDatabase 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:
1. Cache-Aside (Lazy Loading) - Application manages cache2. Read-Through - Cache manages database lookups3. Write-Through - Cache updates on write4. Write-Behind - Async cache updatesFor 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 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 cachedThis pattern is ideal when:
- You need to query data frequently
- You want to fill the cache on demand
- You’re cost-conscious about cache storage
- Your data doesn’t change frequently
How Cache-Aside Works
Here’s the basic flow:
┌─────────────────────────────────────────────────────────────────┐│ ││ 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:
- Hash query parameters to form a cache key
- Check Redis first for cached data
- Return immediately on cache hit
- Query database on cache miss
- 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:
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:
Query: {category: "electronics", price: "100"}Key 1: "product_search:category:electronics|price:100"
Query: {price: "100", category: "electronics"} // Same data, different orderKey 2: "product_search:price:100|category:electronics" // Different key!
Result: Two cache entries for the same query = wasted memorySorting the keys before hashing fixed this issue.
Step 2: Check Redis
Next, I check Redis for the cached result:
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:
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:
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 reductionThe TTL depends on your data freshness requirements:
- 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:
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
await redis.set(cacheKey, JSON.stringify(dbResult));// Data stays forever - stale data problem!await redis.set(cacheKey, JSON.stringify(dbResult), 'EX', 300);Mistake 2: TTL Too Short
TTL = 10 secondsRequests per second = 100Cache hit rate = 10% (only 10 requests within TTL window)Database load = 90 queries/second (almost same as no cache!)Mistake 3: Hash Collision
const key = `search:${params.category}${params.price}`;// "search:electronics100" and "search:electro100nics" could collideconst key = `search:${hashString(sortedParams)}`;Mistake 4: Forgetting Cache Invalidation
1. User searches for "iPhone 15" - cached with price $9992. Admin updates price to $8993. 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:
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 significantlyThe 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:
1. Write-heavy workloads: Cache invalidation overhead outweighs benefits2. Real-time data: TTL can't keep data fresh enough3. Complex queries with many variations: Too many cache keys, low hit rate4. Memory constraints: Cache grows unbounded without limitsFor 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:
- Hash query parameters to generate consistent cache keys
- Check Redis first - return immediately on cache hit
- Query database on miss - store result with appropriate TTL
- Set TTL based on data freshness requirements
- 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