Redis Caching Strategies That Cut My API Response Time by 90%
Practical Redis caching patterns: cache-aside, write-through, write-behind, TTL strategies, and cache invalidation with results.
Our product catalog API was the slowest endpoint in our entire system. Every request joined five tables, applied dynamic pricing rules, and returned a fully hydrated product object. The average response time was 1,200 milliseconds. On the product listing page, which fetched 20 products at once, users waited almost 4 seconds for the page to load. Our frontend team kept asking when the backend would “fix the performance problem.”
The fix took two days. I added a Redis cache layer in front of the database queries, and the average response time dropped from 1,200ms to 85ms — a 93% improvement. The product listing page now loaded in under 400ms. But the real lesson was not that caching is fast. It was that caching is full of subtle traps that can give you stale data, memory pressure, and cache stampedes if you do not design it carefully.
This guide covers the caching strategies I have used across multiple production systems, including the patterns that worked, the patterns that failed, and the implementation details that make the difference.
TL;DR — Caching Patterns at a Glance
| Pattern | Read Performance | Write Complexity | Data Freshness | Best For |
|---|---|---|---|---|
| Cache-Aside | Excellent | Low | Good (TTL-based) | Most read-heavy APIs |
| Read-Through | Excellent | Low | Good | ORM-integrated caching |
| Write-Through | Good | Medium | Excellent | Data that must stay consistent |
| Write-Behind | Good | High | Eventual | Write-heavy workloads |
| Refresh-Ahead | Excellent | Medium | Excellent | Frequently accessed hot data |
If you are starting out: cache-aside with TTL handles 80% of use cases.
Cache-Aside: The Workhorse Pattern
Cache-aside (also called “lazy loading”) is the most common caching pattern, and it is what I reach for first on every project. The application checks the cache before hitting the database. If the data is there (cache hit), return it immediately. If not (cache miss), fetch from the database, store it in the cache, and return it.
const Redis = require('ioredis');
const redis = new Redis();
async function getProduct(productId) {
const cacheKey = `product:${productId}`;
// Step 1: Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Step 2: Cache miss — fetch from database
const product = await db.query(
`SELECT p.*, c.name as category_name,
json_agg(pi.*) as images
FROM products p
JOIN categories c ON p.category_id = c.id
LEFT JOIN product_images pi ON pi.product_id = p.id
WHERE p.id = $1
GROUP BY p.id, c.name`,
[productId]
);
if (!product) return null;
// Step 3: Store in cache with TTL
await redis.set(cacheKey, JSON.stringify(product), 'EX', 300);
return product;
}
Why I Like Cache-Aside
- Simple to understand and implement — the caching logic is explicit in your application code
- Resilient to cache failures — if Redis goes down, the application falls back to the database automatically
- Only caches data that is actually requested — no wasted memory on unused data
The Downside: Cold Starts and Cache Misses
The first request for any uncached item always hits the database. After a Redis restart or cache flush, every request is a cache miss until the cache warms up. I handle this with a cache warming script that pre-loads the most frequently accessed items:
async function warmCache() {
const topProducts = await db.query(
'SELECT id FROM products ORDER BY view_count DESC LIMIT 1000'
);
for (const { id } of topProducts) {
await getProduct(id);
}
console.log(`Warmed cache with ${topProducts.length} products`);
}
I run this script during deployments and after any Redis maintenance window.
Write-Through: Keeping Cache and Database in Sync
Write-through caching updates the cache and the database simultaneously on every write operation. This ensures the cache always has the latest data, eliminating the staleness problem of cache-aside with TTL.
async function updateProduct(productId, updates) {
// Step 1: Update database
const product = await db.query(
'UPDATE products SET name = $1, price = $2 WHERE id = $3 RETURNING *',
[updates.name, updates.price, productId]
);
// Step 2: Update cache immediately
const cacheKey = `product:${productId}`;
await redis.set(cacheKey, JSON.stringify(product), 'EX', 300);
return product;
}
I use write-through for data where staleness is unacceptable — pricing data, inventory counts, and user permissions. The tradeoff is increased write latency (you are writing to both the database and Redis on every update) and the need to handle partial failures (what if the database write succeeds but the Redis write fails?).
Handling Write Failures
async function updateProductSafe(productId, updates) {
const product = await db.query(
'UPDATE products SET name = $1, price = $2 WHERE id = $3 RETURNING *',
[updates.name, updates.price, productId]
);
try {
const cacheKey = `product:${productId}`;
await redis.set(cacheKey, JSON.stringify(product), 'EX', 300);
} catch (err) {
// Cache write failed — invalidate instead of leaving stale data
await redis.del(`product:${productId}`).catch(() => {});
console.error('Cache write failed, invalidated key', err);
}
return product;
}
If the cache write fails, I delete the cache key entirely. This forces the next read to fall through to the database, which always has the correct data. It is better to have a cache miss than stale data.
Write-Behind: Batch Writes for Performance
Write-behind (also called “write-back”) writes to the cache immediately and asynchronously flushes changes to the database in batches. This dramatically improves write performance at the cost of potential data loss if the cache fails before flushing.
I used this pattern for a view counter system. Every page view incremented a counter in Redis, and a background job flushed the accumulated counts to PostgreSQL every 60 seconds.
// Increment view count in Redis (fast)
async function recordPageView(productId) {
await redis.hincrby('page_views:pending', productId, 1);
}
// Flush to database periodically (background job)
async function flushPageViews() {
const pending = await redis.hgetall('page_views:pending');
if (Object.keys(pending).length === 0) return;
const values = Object.entries(pending).map(
([productId, count]) => `('${productId}', ${count})`
).join(', ');
await db.query(`
INSERT INTO product_stats (product_id, view_count)
VALUES ${values}
ON CONFLICT (product_id)
DO UPDATE SET view_count = product_stats.view_count + EXCLUDED.view_count
`);
// Clear pending counts
for (const productId of Object.keys(pending)) {
await redis.hdel('page_views:pending', productId);
}
}
// Run every 60 seconds
setInterval(flushPageViews, 60000);
This approach reduced our database writes from thousands per second to one batch query per minute. The tradeoff is clear: if Redis crashes before flushing, we lose up to 60 seconds of view counts. For analytics data, that is acceptable. For financial transactions, it absolutely is not.
Cache Invalidation: The Hard Part
Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. After years of working with caches, I agree — cache invalidation is where most caching bugs live.
Strategy 1: TTL-Based Expiration
The simplest approach: every cached item has a time-to-live. After the TTL expires, the next request fetches fresh data from the database.
// Cache for 5 minutes
await redis.set(cacheKey, data, 'EX', 300);
TTL works well when you can tolerate slightly stale data. I use TTL of 60-300 seconds for product data, 5-10 seconds for inventory counts, and 3600 seconds for static configuration.
Strategy 2: Event-Based Invalidation
When data changes, publish an event that triggers cache invalidation:
// When a product is updated
async function updateProduct(productId, updates) {
const product = await db.query('UPDATE products SET ... WHERE id = $1', [...]);
// Invalidate all related cache keys
await redis.del(`product:${productId}`);
await redis.del(`product_list:category:${product.categoryId}`);
await redis.del('product_list:featured');
// Publish event for other services
await eventBus.publish('product.updated', { productId });
return product;
}
Event-based invalidation gives you immediate consistency but requires you to know every cache key that depends on the changed data. Missing a key means stale data. I maintain a cache dependency map that documents which cache keys are affected by each type of data change.
Strategy 3: Cache Tags (My Preferred Approach)
Cache tags let you group related cache entries and invalidate them all at once. Redis does not support tags natively, but you can implement them with sets:
async function setWithTags(key, value, ttl, tags) {
const pipe = redis.pipeline();
pipe.set(key, JSON.stringify(value), 'EX', ttl);
for (const tag of tags) {
pipe.sadd(`tag:${tag}`, key);
pipe.expire(`tag:${tag}`, ttl + 60);
}
await pipe.exec();
}
async function invalidateByTag(tag) {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
await redis.del(...keys);
}
await redis.del(`tag:${tag}`);
}
// Usage
await setWithTags(
`product:${productId}`,
productData,
300,
[`category:${categoryId}`, 'products', `brand:${brandId}`]
);
// Invalidate all products in a category
await invalidateByTag(`category:${categoryId}`);
The Cache Stampede Problem
A cache stampede happens when a popular cache key expires and hundreds of concurrent requests all miss the cache simultaneously, all hitting the database at once. I experienced this with our homepage product carousel — a cached query that served 200 requests per second expired, and 200 database queries fired at the same instant.
Solution: Mutex Lock
Only allow one request to regenerate the cache while others wait:
async function getWithMutex(key, fetchFn, ttl) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (acquired) {
try {
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), 'EX', ttl);
return data;
} finally {
await redis.del(lockKey);
}
}
// Lock not acquired — wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
return getWithMutex(key, fetchFn, ttl);
}
Solution: Stale-While-Revalidate
Serve stale data while refreshing in the background:
async function getWithSWR(key, fetchFn, ttl, staleTtl) {
const cached = await redis.get(key);
if (cached) {
const { data, expiresAt } = JSON.parse(cached);
if (Date.now() < expiresAt) {
return data; // Fresh data
}
// Data is stale — return it but refresh in background
refreshInBackground(key, fetchFn, ttl, staleTtl);
return data;
}
// No cached data at all — must fetch synchronously
const data = await fetchFn();
const entry = { data, expiresAt: Date.now() + ttl * 1000 };
await redis.set(key, JSON.stringify(entry), 'EX', ttl + staleTtl);
return data;
}
async function refreshInBackground(key, fetchFn, ttl, staleTtl) {
const lockKey = `refresh:${key}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 30);
if (!acquired) return;
try {
const data = await fetchFn();
const entry = { data, expiresAt: Date.now() + ttl * 1000 };
await redis.set(key, JSON.stringify(entry), 'EX', ttl + staleTtl);
} finally {
await redis.del(lockKey);
}
}
I prefer stale-while-revalidate for most production systems. Users get an instant response (even if slightly stale), and the cache refreshes itself in the background. The only time I use mutex locks is when serving stale data is unacceptable — pricing data, inventory counts, or anything financial.
Memory Management
Redis stores everything in memory, so managing memory usage is critical. I have had Redis instances run out of memory and start evicting cache keys randomly, causing unpredictable cache misses and database spikes.
Setting Memory Limits
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
The allkeys-lru eviction policy removes the least recently used keys when memory is full. This is the safest default for caching use cases. Other policies I have used:
| Policy | Behavior | Use Case |
|---|---|---|
allkeys-lru | Evict least recently used | General caching |
volatile-lru | Evict LRU keys with TTL set | Mixed cache + persistent data |
allkeys-lfu | Evict least frequently used | Hot/cold data patterns |
noeviction | Return errors when full | Session storage (never lose data) |
Monitoring Cache Effectiveness
Track these metrics to know if your cache is working:
// Track hit/miss ratios
async function getCached(key, fetchFn, ttl) {
const cached = await redis.get(key);
if (cached) {
metrics.increment('cache.hit', { key_prefix: key.split(':')[0] });
return JSON.parse(cached);
}
metrics.increment('cache.miss', { key_prefix: key.split(':')[0] });
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), 'EX', ttl);
return data;
}
A healthy cache has a hit ratio above 90%. If your ratio is lower, either your TTL is too short, your cache keys are too specific, or you are caching data that is rarely accessed twice.
Frequently Asked Questions
What TTL Should I Set for My Cache?
It depends on how stale the data can be without causing problems. I use: 5-10 seconds for inventory counts and rapidly changing data, 60-300 seconds for product details and content, 3600+ seconds for static configuration and reference data. Start with a longer TTL and reduce it if users report stale data.
Should I Cache Database Query Results or Application Objects?
Cache application objects (the fully processed result that your API returns), not raw database rows. This saves the application from re-processing cached data and ensures the cache hit path is as fast as possible. The tradeoff is that cache invalidation becomes more complex when one database row affects multiple cached objects.
How Do I Handle Cache in a Multi-Server Setup?
Use a centralized Redis instance that all servers connect to. Never use in-memory caches (like Node.js Maps or Python dicts) for data that needs to be consistent across servers. Each server’s local cache will drift out of sync, leading to inconsistent responses depending on which server handles the request.
What Happens If Redis Goes Down?
If you are using cache-aside, your application automatically falls back to the database. Response times will increase, but the application remains functional. Design your system so that Redis is a performance optimization, not a hard dependency. The exception is when Redis stores session data — in that case, use Redis Sentinel or Redis Cluster for high availability.
Is Redis the Best Caching Solution?
For most backend caching needs, yes. Redis is fast, feature-rich, and has excellent client library support in every language. Alternatives include Memcached (simpler, multi-threaded, slightly faster for simple key-value operations) and application-level caches (zero network latency but not shared across servers). I default to Redis because its data structures (sorted sets, hashes, lists) solve problems that go beyond simple caching.
The Bottom Line
Redis caching transformed our slowest API endpoint into one of our fastest, and the patterns in this guide have been consistently effective across every project I have applied them to. Start with cache-aside and TTL-based expiration — it handles the vast majority of caching needs with minimal complexity. Add write-through for data that must stay current. Use stale-while-revalidate to eliminate cache stampedes on popular keys.
The most important lesson I have learned about caching is that it is not just a performance optimization — it is a system design decision with implications for data consistency, memory management, and failure modes. Design your caching strategy deliberately, monitor your hit ratios, and always have a plan for what happens when the cache is empty.
Product recommendations are based on independent research and testing. We may earn a commission through affiliate links at no extra cost to you.