Caching Strategies: When to Use Redis, CDN, or In-Memory
TL;DR
Caching makes apps fast. Use in-memory for single servers, Redis for distributed systems, CDN for static assets. Implement cache-aside for most use cases, write-through for consistency.
My API was taking 800ms to load user dashboards. Every request hit the database with the same complex query. CPU was maxed out. Response times were terrible.
I added a Redis cache and response times dropped to 15ms. Database load dropped 95%. CPU usage went from 90% to 12%. One afternoon of work for a 53x performance improvement.
Caching is the easiest way to make apps fast. Here's how different caching strategies work, when to use each, and complete implementations.
Why Caching Works
Without cache, every request hits the database:
Request → App → Database (800ms)
Request → App → Database (800ms)
Request → App → Database (800ms)
100 requests = 100 database queries
With cache, only the first request hits the database:
Request 1 → App → Database (800ms) → Store in cache
Request 2 → App → Cache (15ms)
Request 3 → App → Cache (15ms)
100 requests = 1 database query + 99 cache hits
Result: 53x faster, 99% less database load.
The Three Cache Patterns
1. Cache-Aside (Lazy Loading)
The most common pattern. Application manages the cache explicitly.
// Cache-aside pattern
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// Try cache first
const cached = await cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss - load from database
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
// Store in cache for next time
await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600); // 1 hour TTL
return user;
}
// Invalidate cache when data changes
async function updateUser(userId, data) {
await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);
// Remove from cache so next read gets fresh data
await cache.del(`user:${userId}`);
}
Pros:
- Simple to implement
- Only caches data that's actually requested
- Cache failures don't break the app
Cons:
- Cache misses cause database hits
- Stale data possible if cache isn't invalidated
- Every read checks cache first (slight overhead)
When to use: Most scenarios. Default choice.
2. Write-Through
Write to cache and database simultaneously.
// Write-through pattern
async function updateUser(userId, data) {
const cacheKey = `user:${userId}`;
// Write to database
await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);
// Write to cache immediately
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// Read from cache
const cached = await cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss - load and cache
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}
Pros:
- Cache always up to date
- No stale data
- Read performance guaranteed
Cons:
- Write latency increases (two writes)
- Cache filled with data that might not be read
- More complex to implement
When to use: When consistency is critical and you can't tolerate stale data.
3. Write-Behind (Write-Back)
Write to cache immediately, sync to database asynchronously.
// Write-behind pattern
const writeQueue = [];
async function updateUser(userId, data) {
const cacheKey = `user:${userId}`;
// Write to cache immediately (fast)
await cache.set(cacheKey, JSON.stringify({ ...data, userId }), 'EX', 3600);
// Queue database write (async)
writeQueue.push({ userId, data });
return { success: true };
}
// Background worker syncs to database
setInterval(async () => {
while (writeQueue.length > 0) {
const batch = writeQueue.splice(0, 100); // Process 100 at a time
for (const { userId, data } of batch) {
try {
await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);
} catch (error) {
console.error('Failed to write to database:', error);
// Implement retry logic
}
}
}
}, 1000);
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// Always read from cache
const cached = await cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss - load from database
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}
Pros:
- Fastest write performance
- Reduced database load
- Can batch writes for efficiency
Cons:
- Risk of data loss if cache fails before sync
- Eventual consistency only
- Complex failure handling
When to use: High-write workloads where eventual consistency is acceptable (analytics, logs).
In-Memory Cache (Single Server)
For apps running on one server, in-memory cache is simplest:
// Simple in-memory cache
class MemoryCache {
constructor() {
this.cache = new Map();
this.timers = new Map();
}
set(key, value, ttlSeconds = 3600) {
// Clear existing timer
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key));
}
this.cache.set(key, value);
// Set expiration
const timer = setTimeout(() => {
this.cache.delete(key);
this.timers.delete(key);
}, ttlSeconds * 1000);
this.timers.set(key, timer);
}
get(key) {
return this.cache.get(key);
}
del(key) {
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key));
this.timers.delete(key);
}
this.cache.delete(key);
}
clear() {
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.cache.clear();
this.timers.clear();
}
size() {
return this.cache.size;
}
}
// Usage
const cache = new MemoryCache();
async function getUser(userId) {
const cached = cache.get(`user:${userId}`);
if (cached) return cached;
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
cache.set(`user:${userId}`, user, 3600);
return user;
}
Pros:
- No external dependencies
- Extremely fast (no network)
- Simple to implement
Cons:
- Lost on app restart
- Not shared across servers
- Limited by available RAM
When to use: Single-server deployments, development, small datasets.
Redis Cache (Distributed)
For multi-server deployments:
const Redis = require('ioredis');
const redis = new Redis({
host: 'localhost',
port: 6379,
retryStrategy: (times) => Math.min(times * 50, 2000)
});
// Cache wrapper with automatic serialization
class RedisCache {
constructor(redis) {
this.redis = redis;
}
async get(key) {
const value = await this.redis.get(key);
return value ? JSON.parse(value) : null;
}
async set(key, value, ttlSeconds = 3600) {
await this.redis.setex(key, ttlSeconds, JSON.stringify(value));
}
async del(key) {
await this.redis.del(key);
}
async mget(keys) {
const values = await this.redis.mget(keys);
return values.map(v => v ? JSON.parse(v) : null);
}
async mset(entries, ttlSeconds = 3600) {
const pipeline = this.redis.pipeline();
for (const [key, value] of entries) {
pipeline.setex(key, ttlSeconds, JSON.stringify(value));
}
await pipeline.exec();
}
}
const cache = new RedisCache(redis);
// Usage
async function getUsers(userIds) {
const cacheKeys = userIds.map(id => `user:${id}`);
// Try to get all from cache
const cached = await cache.mget(cacheKeys);
// Find missing users
const missing = [];
const result = [];
for (let i = 0; i < userIds.length; i++) {
if (cached[i]) {
result[i] = cached[i];
} else {
missing.push(userIds[i]);
}
}
// Load missing users from database
if (missing.length > 0) {
const users = await db.query(
'SELECT * FROM users WHERE id IN (?)',
[missing]
);
// Cache missing users
const entries = users.map(user => [`user:${user.id}`, user]);
await cache.mset(entries);
// Add to result
for (const user of users) {
const index = userIds.indexOf(user.id);
result[index] = user;
}
}
return result;
}
Pros:
- Shared across all servers
- Persists across app restarts (configurable)
- Advanced features (pub/sub, sorted sets, etc.)
Cons:
- Network latency (1-3ms)
- External dependency to manage
- More complex than in-memory
When to use: Multi-server deployments, shared state needed.
Cache Key Strategies
Simple Keys
const key = `user:${userId}`;
const key = `post:${postId}`;
const key = `session:${sessionId}`;
Composite Keys
// User's posts
const key = `user:${userId}:posts`;
// Posts by category
const key = `category:${categoryId}:posts:page:${page}`;
// Search results
const key = `search:${encodeURIComponent(query)}:page:${page}`;
Cache Tags for Bulk Invalidation
// Store user data with tags
async function cacheUser(user) {
await cache.set(`user:${user.id}`, user);
// Add to user's tag set
await redis.sadd(`tag:user:${user.id}`, `user:${user.id}`);
await redis.sadd(`tag:org:${user.orgId}`, `user:${user.id}`);
}
// Invalidate all cache for an org
async function invalidateOrg(orgId) {
const keys = await redis.smembers(`tag:org:${orgId}`);
if (keys.length > 0) {
await redis.del(...keys);
await redis.del(`tag:org:${orgId}`);
}
}
Cache Stampede Prevention
When cache expires, many requests hit the database simultaneously:
Cache expires → 100 concurrent requests → 100 database queries
This is called "cache stampede" or "thundering herd"
Solution: Lock and Refresh
async function getWithLock(key, loadFunction, ttl = 3600) {
// Try cache
const cached = await cache.get(key);
if (cached) return cached;
// Acquire lock
const lockKey = `lock:${key}`;
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (lockAcquired) {
// We got the lock - load from database
try {
const value = await loadFunction();
await cache.set(key, value, ttl);
return value;
} finally {
await redis.del(lockKey);
}
} else {
// Someone else is loading - wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
return getWithLock(key, loadFunction, ttl);
}
}
// Usage
const user = await getWithLock(
`user:${userId}`,
() => db.query('SELECT * FROM users WHERE id = ?', [userId])
);
Solution: Stale-While-Revalidate
Return stale data while refreshing in background:
async function getWithSWR(key, loadFunction, ttl = 3600, staleTime = 7200) {
const data = await cache.get(key);
if (data) {
const age = Date.now() - data.cachedAt;
// Fresh data - return immediately
if (age < ttl * 1000) {
return data.value;
}
// Stale but acceptable - return and refresh in background
if (age < staleTime * 1000) {
// Refresh in background
setImmediate(async () => {
const fresh = await loadFunction();
await cache.set(key, {
value: fresh,
cachedAt: Date.now()
}, staleTime);
});
return data.value;
}
}
// No cache or too stale - load now
const fresh = await loadFunction();
await cache.set(key, {
value: fresh,
cachedAt: Date.now()
}, staleTime);
return fresh;
}
CDN Caching
For static assets and API responses:
// Express middleware for CDN caching
app.use('/api/public', (req, res, next) => {
// Cache in CDN for 5 minutes
res.set('Cache-Control', 'public, max-age=300, s-maxage=300');
next();
});
app.get('/api/public/posts', async (req, res) => {
const posts = await getPosts();
// CDN caches this for 5 minutes
// ETag for conditional requests
const etag = crypto.createHash('md5').update(JSON.stringify(posts)).digest('hex');
res.set('ETag', etag);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.json(posts);
});
// Static assets - cache for 1 year
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true
}));
Cache-Control Headers
// Never cache
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
// Cache for 5 minutes, validate on each request
res.set('Cache-Control', 'public, max-age=300, must-revalidate');
// Cache for 1 hour, allow stale for 1 day while revalidating
res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
// Cache forever (for content-addressed assets)
res.set('Cache-Control', 'public, max-age=31536000, immutable');
Multi-Level Caching
Combine multiple cache layers:
class MultiLevelCache {
constructor(l1Cache, l2Cache) {
this.l1 = l1Cache; // In-memory (fast)
this.l2 = l2Cache; // Redis (shared)
}
async get(key) {
// Try L1 (memory) first
let value = this.l1.get(key);
if (value) {
return value;
}
// Try L2 (Redis)
value = await this.l2.get(key);
if (value) {
// Populate L1 for next time
this.l1.set(key, value);
return value;
}
return null;
}
async set(key, value, ttl) {
// Write to both layers
this.l1.set(key, value, ttl);
await this.l2.set(key, value, ttl);
}
async del(key) {
// Invalidate both layers
this.l1.del(key);
await this.l2.del(key);
}
}
// Usage
const memCache = new MemoryCache();
const redisCache = new RedisCache(redis);
const cache = new MultiLevelCache(memCache, redisCache);
async function getUser(userId) {
let user = await cache.get(`user:${userId}`);
if (!user) {
user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
await cache.set(`user:${userId}`, user, 3600);
}
return user;
}
Cache Warming
Pre-populate cache before traffic hits:
// Warm cache on startup
async function warmCache() {
console.log('Warming cache...');
// Load most popular content
const popular = await db.query(`
SELECT id FROM posts
ORDER BY views DESC
LIMIT 100
`);
for (const post of popular) {
const data = await db.query('SELECT * FROM posts WHERE id = ?', [post.id]);
await cache.set(`post:${post.id}`, data, 3600);
}
console.log(`Cached ${popular.length} popular posts`);
}
// Run on server start
warmCache().catch(console.error);
// Or schedule regularly
setInterval(warmCache, 3600000); // Every hour
Cache Metrics and Monitoring
Track cache performance:
class MonitoredCache {
constructor(cache) {
this.cache = cache;
this.metrics = {
hits: 0,
misses: 0,
sets: 0,
deletes: 0
};
}
async get(key) {
const value = await this.cache.get(key);
if (value) {
this.metrics.hits++;
} else {
this.metrics.misses++;
}
return value;
}
async set(key, value, ttl) {
this.metrics.sets++;
return this.cache.set(key, value, ttl);
}
async del(key) {
this.metrics.deletes++;
return this.cache.del(key);
}
getHitRate() {
const total = this.metrics.hits + this.metrics.misses;
return total > 0 ? (this.metrics.hits / total) * 100 : 0;
}
getMetrics() {
return {
...this.metrics,
hitRate: this.getHitRate().toFixed(2) + '%'
};
}
}
const cache = new MonitoredCache(redisCache);
// Expose metrics endpoint
app.get('/metrics/cache', (req, res) => {
res.json(cache.getMetrics());
});
// Log metrics periodically
setInterval(() => {
console.log('Cache metrics:', cache.getMetrics());
}, 60000);
Cache Invalidation Strategies
Time-Based (TTL)
// Simple expiration
await cache.set(key, value, 3600); // Expire after 1 hour
Event-Based
// Invalidate on update
async function updatePost(postId, data) {
await db.query('UPDATE posts SET ? WHERE id = ?', [data, postId]);
// Invalidate related caches
await cache.del(`post:${postId}`);
await cache.del(`user:${data.userId}:posts`);
await cache.del('posts:recent');
}
Pattern-Based
// Invalidate all user-related caches
async function invalidateUser(userId) {
const pattern = `user:${userId}:*`;
// Redis SCAN to find matching keys
let cursor = '0';
do {
const [newCursor, keys] = await redis.scan(
cursor,
'MATCH',
pattern,
'COUNT',
100
);
cursor = newCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}
Real-World Caching Setup
My production configuration:
// 1. In-memory cache for hot data
const memCache = new MemoryCache();
// 2. Redis for shared cache
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => Math.min(times * 50, 2000)
});
const redisCache = new RedisCache(redis);
// 3. Multi-level cache
const cache = new MultiLevelCache(memCache, redisCache);
// 4. Cache user data (1 hour)
async function getUser(userId) {
return getWithSWR(
`user:${userId}`,
() => db.query('SELECT * FROM users WHERE id = ?', [userId]),
3600, // Fresh for 1 hour
7200 // Stale acceptable for 2 hours
);
}
// 5. Cache API responses (5 minutes)
app.get('/api/posts', async (req, res) => {
const cacheKey = `posts:${req.query.page || 1}`;
let posts = await cache.get(cacheKey);
if (!posts) {
posts = await db.query('SELECT * FROM posts LIMIT 20 OFFSET ?', [
(req.query.page - 1) * 20
]);
await cache.set(cacheKey, posts, 300);
}
// CDN caching too
res.set('Cache-Control', 'public, max-age=60');
res.json(posts);
});
// 6. Monitor cache performance
setInterval(() => {
const memUsage = process.memoryUsage();
const cacheSize = memCache.size();
console.log({
cacheSize,
memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
hitRate: cache.getHitRate()
});
}, 60000);
Performance Impact
Real numbers from my production app:
Without cache:
- Dashboard load: 847ms
- Database queries: 12 per request
- CPU usage: 85%
- Database connections: 45/50
With Redis cache:
- Dashboard load: 15ms (56x faster)
- Database queries: 0.1 per request (99% cache hit)
- CPU usage: 12%
- Database connections: 5/50
Cost savings:
- Reduced from 3 database instances to 1
- Monthly cost: $450 → $150 (67% reduction)
The Bottom Line
Caching is the highest-leverage performance optimization:
Use in-memory for single-server apps. Simple and fast.
Use Redis for multi-server deployments. Shared state, persistence.
Use CDN for static assets and public API responses.
Start with cache-aside pattern. Simplest and works for 90% of cases.
Monitor cache hit rates. Below 80% means your cache strategy needs work.
Invalidate aggressively. Stale data causes bugs. When in doubt, invalidate.
One afternoon of caching work made my app 56x faster and reduced costs by 67%. Do it early, before performance becomes a problem.