Caching Strategies for Modern Applications: When and How to Cache
Caching is one of the most effective ways to improve application performance. A well-implemented cache can reduce response times by orders of magnitude and significantly decrease database load. After implementing caching in production systems serving millions of requests, I've learned what works and what doesn't.
Why Cache?
Benefits
- Faster responses - Serve data from memory
- Reduced database load - Fewer queries
- Better scalability - Handle more traffic
- Cost savings - Less database resources needed
- Improved user experience - Lower latency
Types of Caching
1. Application-Level Caching
In-memory caching within your application:
// Simple in-memory cache
const cache = new Map();
async function getUser(userId) {
// Check cache first
if (cache.has(userId)) {
return cache.get(userId);
}
// Fetch from database
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// Store in cache
cache.set(userId, user);
return user;
}
2. Distributed Caching (Redis)
Shared cache across multiple servers:
const redis = require('redis');
const client = redis.createClient();
async function getUser(userId) {
// Check Redis cache
const cached = await client.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Fetch from database
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// Store in Redis (expire in 1 hour)
await client.setEx(`user:${userId}`, 3600, JSON.stringify(user));
return user;
}
3. HTTP Caching
Browser and CDN caching:
// Set cache headers
app.get('/api/users/:id', (req, res) => {
const user = getUser(req.params.id);
res.set('Cache-Control', 'public, max-age=3600');
res.set('ETag', generateETag(user));
res.json(user);
});
4. Database Query Caching
Cache query results:
async function getPopularProducts() {
const cacheKey = 'popular-products';
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Execute query
const products = await db.query(`
SELECT * FROM products
WHERE views > 1000
ORDER BY views DESC
LIMIT 10
`);
// Cache for 5 minutes
await redis.setEx(cacheKey, 300, JSON.stringify(products));
return products;
}
Caching Patterns
1. Cache-Aside (Lazy Loading)
Application checks cache, loads from database if miss:
async function getData(key) {
// Check cache
let data = await cache.get(key);
if (!data) {
// Cache miss - load from database
data = await database.get(key);
// Store in cache
await cache.set(key, data, { ttl: 3600 });
}
return data;
}
Use when: Read-heavy workloads, data changes infrequently
2. Write-Through
Write to cache and database simultaneously:
async function updateData(key, value) {
// Write to database
await database.update(key, value);
// Write to cache
await cache.set(key, value, { ttl: 3600 });
}
Use when: Consistency is critical
3. Write-Back (Write-Behind)
Write to cache first, write to database asynchronously:
async function updateData(key, value) {
// Write to cache immediately
await cache.set(key, value, { ttl: 3600 });
// Write to database asynchronously
setImmediate(async () => {
await database.update(key, value);
});
}
Use when: Write performance is critical, can tolerate some data loss
4. Refresh-Ahead
Proactively refresh cache before expiration:
async function getData(key) {
let data = await cache.get(key);
if (!data) {
// Cache miss - load from database
data = await database.get(key);
await cache.set(key, data, { ttl: 3600 });
} else {
// Check if near expiration
const ttl = await cache.ttl(key);
if (ttl < 300) { // Less than 5 minutes left
// Refresh in background
setImmediate(async () => {
const fresh = await database.get(key);
await cache.set(key, fresh, { ttl: 3600 });
});
}
}
return data;
}
Use when: Data must always be fresh, can't wait for cache miss
What to Cache
Good Candidates
- User sessions - Frequently accessed, rarely change
- Product catalogs - Read-heavy, changes infrequently
- Configuration data - Rarely changes
- Computed results - Expensive calculations
- API responses - External API calls
Bad Candidates
- Frequently changing data - Cache invalidation overhead
- Large objects - Memory constraints
- Sensitive data - Security concerns
- Real-time data - Must be fresh
- Unique data - No reuse benefit
Cache Invalidation
Time-Based Expiration
// Set expiration time
await cache.set('key', 'value', { ttl: 3600 }); // 1 hour
Event-Based Invalidation
// Invalidate on update
async function updateUser(userId, data) {
await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
// Invalidate cache
await cache.del(`user:${userId}`);
}
Tag-Based Invalidation
// Cache with tags
await cache.set('user:123', userData, { tags: ['user', 'profile'] });
// Invalidate all items with tag
await cache.invalidateTags(['user']);
Cache Keys
Good Key Design
// Hierarchical keys
`user:${userId}`
`user:${userId}:profile`
`user:${userId}:orders`
`product:${productId}`
`product:${productId}:reviews`
// Versioned keys
`user:${userId}:v2`
Avoid
// Bad: Unclear keys
`data1`
`temp`
`cache`
Real-World Example
Challenge: E-commerce site, product page taking 2+ seconds, database overloaded.
Solution: Multi-layer caching
// Layer 1: Application cache (hot data)
const hotCache = new Map(); // In-memory
// Layer 2: Redis (warm data)
const redis = require('redis');
const redisClient = redis.createClient();
// Layer 3: Database (cold data)
async function getProduct(productId) {
// Check hot cache (in-memory)
if (hotCache.has(productId)) {
return hotCache.get(productId);
}
// Check Redis cache
const cached = await redisClient.get(`product:${productId}`);
if (cached) {
const product = JSON.parse(cached);
// Promote to hot cache
hotCache.set(productId, product);
return product;
}
// Load from database
const product = await db.query(
'SELECT * FROM products WHERE id = $1',
[productId]
);
// Store in both caches
hotCache.set(productId, product);
await redisClient.setEx(
`product:${productId}`,
3600,
JSON.stringify(product)
);
return product;
}
Result:
- Response time: 2s → 50ms (40x improvement)
- Database load: Reduced by 80%
- User experience: Significantly improved
Best Practices
- Cache at the right level - Application, database, HTTP
- Set appropriate TTLs - Balance freshness and performance
- Invalidate properly - Keep cache consistent
- Monitor cache hit rates - Measure effectiveness
- Handle cache failures - Don't break if cache is down
- Use consistent keys - Easy to manage
- Consider cache size - Memory constraints
- Test cache behavior - Ensure it works correctly
Common Pitfalls
1. Cache Stampede
Problem: Many requests miss cache simultaneously
Solution: Use locks or probabilistic early expiration
2. Stale Data
Problem: Cache not invalidated on updates
Solution: Invalidate on writes
3. Memory Exhaustion
Problem: Cache grows unbounded
Solution: Set size limits, use LRU eviction
4. Cache Warming
Problem: Cold cache on restart
Solution: Pre-populate cache on startup
Conclusion
Caching is a powerful tool for improving performance, but it requires careful implementation. The key is to:
- Cache the right data - Frequently accessed, rarely changed
- Use appropriate patterns - Cache-aside, write-through, etc.
- Invalidate properly - Keep data fresh
- Monitor and optimize - Measure hit rates, adjust strategy
Remember: Caching is a trade-off between freshness and performance. Find the right balance for your use case.
What caching strategies have you implemented? What challenges have you faced?
Related Posts
Optimizing Frontend Performance: Beyond Code Splitting
Advanced frontend performance optimization techniques that go beyond basic code splitting. Learn how to achieve sub-second load times and smooth 60fps interactions.
Scaling Applications Horizontally: Strategies for Growth
Learn how to scale applications horizontally to handle millions of users. From load balancing to database sharding, master the techniques that enable growth.
Performance Optimization in Node.js: Real-World Techniques
Discover practical Node.js performance optimization techniques that have helped applications handle millions of requests. From async patterns to memory management.
Database Optimization Strategies for High-Traffic Applications
Learn proven database optimization techniques that have helped applications handle millions of queries per day. From indexing strategies to query optimization, this guide covers it all.
Serverless Architecture: When to Use and When to Avoid
A practical guide to serverless architecture. Learn when serverless makes sense, its trade-offs, and how to build effective serverless applications.
Event-Driven Architecture: Patterns and Best Practices
Learn how to build scalable, decoupled systems using event-driven architecture. Discover patterns, message brokers, and real-world implementation strategies.