Caching Strategies
Speed Through Strategic Data Storage
Explanation
Why Cache?
Caching stores copies of data to serve future requests faster. It reduces database load, decreases latency, and improves scalability.
Cache Levels
- Browser Cache: Client-side (HTTP headers)
- CDN Cache: Edge servers
- Application Cache: In-memory (Redis)
- Database Cache: Query caching
Cache Strategies
| Strategy | Description | Use Case | |----------|-------------|----------| | Cache-Aside | App manages cache | General purpose | | Read-Through | Cache manages reads | Consistent reads | | Write-Through | Sync write to cache+DB | Data consistency | | Write-Behind | Async write to DB | High write volume |
Demonstration
Example 1: HTTP Caching
// Express middleware for cache headers
function cacheControl(options = {}) {
const {
maxAge = 3600,
sMaxAge = null,
private: isPrivate = false,
noCache = false,
noStore = false,
mustRevalidate = false
} = options;
return (req, res, next) => {
if (noStore) {
res.set('Cache-Control', 'no-store');
} else if (noCache) {
res.set('Cache-Control', 'no-cache');
} else {
const directives = [];
directives.push(isPrivate ? 'private' : 'public');
directives.push(`max-age=${maxAge}`);
if (sMaxAge !== null) {
directives.push(`s-maxage=${sMaxAge}`);
}
if (mustRevalidate) {
directives.push('must-revalidate');
}
res.set('Cache-Control', directives.join(', '));
}
next();
};
}
// Usage
// Public, cached for 1 hour
app.get('/api/products',
cacheControl({ maxAge: 3600, sMaxAge: 7200 }),
getProducts
);
// Private, user-specific
app.get('/api/profile',
cacheControl({ maxAge: 300, private: true }),
getProfile
);
// Never cache
app.get('/api/transactions',
cacheControl({ noStore: true }),
getTransactions
);
// ETag for conditional requests
const etag = require('etag');
app.get('/api/data', async (req, res) => {
const data = await fetchData();
const body = JSON.stringify(data);
const hash = etag(body);
res.set('ETag', hash);
res.set('Cache-Control', 'private, max-age=0, must-revalidate');
// Check If-None-Match header
if (req.headers['if-none-match'] === hash) {
return res.status(304).end(); // Not Modified
}
res.json(data);
});
Example 2: Redis Caching
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
// Cache-Aside Pattern
class CacheService {
constructor(redis, defaultTTL = 3600) {
this.redis = redis;
this.defaultTTL = defaultTTL;
}
async get(key) {
const cached = await this.redis.get(key);
return cached ? JSON.parse(cached) : null;
}
async set(key, value, ttl = this.defaultTTL) {
await this.redis.setex(key, ttl, JSON.stringify(value));
}
async delete(key) {
await this.redis.del(key);
}
async deletePattern(pattern) {
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
// Cache-aside helper
async getOrSet(key, fetchFn, ttl = this.defaultTTL) {
let value = await this.get(key);
if (value === null) {
value = await fetchFn();
await this.set(key, value, ttl);
}
return value;
}
}
const cache = new CacheService(redis);
// Usage in API
app.get('/api/users/:id', async (req, res) => {
const cacheKey = `user:${req.params.id}`;
const user = await cache.getOrSet(
cacheKey,
() => User.findById(req.params.id),
3600 // 1 hour
);
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
res.json({ data: user });
});
// Invalidate on update
app.put('/api/users/:id', async (req, res) => {
const user = await User.findByIdAndUpdate(req.params.id, req.body);
// Invalidate cache
await cache.delete(`user:${req.params.id}`);
res.json({ data: user });
});
Example 3: In-Memory Caching
// Simple in-memory cache with LRU
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
// Move to end (most recently used)
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value);
return value.data;
}
set(key, data, ttl = 0) {
// Delete if exists (to update position)
this.cache.delete(key);
// Evict oldest if at capacity
if (this.cache.size >= this.maxSize) {
const oldest = this.cache.keys().next().value;
this.cache.delete(oldest);
}
this.cache.set(key, {
data,
expiresAt: ttl > 0 ? Date.now() + ttl * 1000 : 0
});
}
isExpired(key) {
const item = this.cache.get(key);
if (!item) return true;
if (item.expiresAt === 0) return false;
return Date.now() > item.expiresAt;
}
getOrSet(key, fetchFn, ttl = 0) {
if (this.cache.has(key) && !this.isExpired(key)) {
return this.get(key);
}
const data = fetchFn();
this.set(key, data, ttl);
return data;
}
clear() {
this.cache.clear();
}
}
// Node-cache alternative
const NodeCache = require('node-cache');
const cache = new NodeCache({
stdTTL: 600, // Default TTL 10 minutes
checkperiod: 120, // Check for expired keys every 2 minutes
useClones: false // Don't clone on get (faster)
});
// Memoization for expensive functions
function memoize(fn, options = {}) {
const { maxAge = 60000, maxSize = 100 } = options;
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.value;
}
const value = fn.apply(this, args);
cache.set(key, {
value,
expiresAt: Date.now() + maxAge
});
// Size limit
if (cache.size > maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
return value;
};
}
Example 4: Cache Invalidation
// Tag-based invalidation
class TaggedCache {
constructor(redis) {
this.redis = redis;
}
async set(key, value, { tags = [], ttl = 3600 } = {}) {
const pipeline = this.redis.pipeline();
// Store value
pipeline.setex(key, ttl, JSON.stringify(value));
// Associate with tags
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttl);
}
await pipeline.exec();
}
async get(key) {
const value = await this.redis.get(key);
return value ? JSON.parse(value) : null;
}
async invalidateTag(tag) {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
const pipeline = this.redis.pipeline();
keys.forEach(key => pipeline.del(key));
pipeline.del(`tag:${tag}`);
await pipeline.exec();
}
}
}
const cache = new TaggedCache(redis);
// Cache with tags
await cache.set('product:123', product, {
tags: ['products', `category:${product.categoryId}`],
ttl: 3600
});
// Invalidate all products in category
await cache.invalidateTag(`category:${categoryId}`);
// Event-driven invalidation
const EventEmitter = require('events');
const cacheEvents = new EventEmitter();
cacheEvents.on('user:updated', async (userId) => {
await cache.delete(`user:${userId}`);
await cache.deletePattern(`user:${userId}:*`);
});
cacheEvents.on('product:updated', async (productId) => {
await cache.invalidateTag(`product:${productId}`);
});
// Emit events from services
async function updateUser(userId, data) {
const user = await User.findByIdAndUpdate(userId, data);
cacheEvents.emit('user:updated', userId);
return user;
}
Example 5: CDN and Edge Caching
// Vercel edge caching
export const config = {
runtime: 'edge'
};
export default async function handler(req) {
const data = await fetchData();
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 's-maxage=3600, stale-while-revalidate=86400'
}
});
}
// stale-while-revalidate
// Serves stale content while fetching fresh content in background
app.get('/api/feed', (req, res) => {
res.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=600');
// Cache for 60s, serve stale for up to 10 min while refreshing
});
// Vary header for different cached versions
app.get('/api/content', (req, res) => {
res.set('Vary', 'Accept-Language, Accept-Encoding');
// Different cache for different languages
});
// Surrogate keys for CDN invalidation (Fastly, Cloudflare)
app.get('/api/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
res.set('Surrogate-Key', `product-${product.id} category-${product.categoryId}`);
res.set('Cache-Control', 's-maxage=86400');
res.json({ data: product });
});
// Invalidate via API
async function invalidateProduct(productId) {
await fetch('https://api.fastly.com/purge/product-' + productId, {
method: 'POST',
headers: { 'Fastly-Key': process.env.FASTLY_KEY }
});
}
Key Takeaways:
- Cache at multiple levels
- Use appropriate TTLs
- Implement cache invalidation strategy
- Consider stale-while-revalidate
- Tag-based invalidation scales better
Imitation
Challenge 1: Implement Read-Through Cache
Task: Create a read-through cache wrapper for database queries.
Solution
class ReadThroughCache {
constructor(redis, repository, options = {}) {
this.redis = redis;
this.repository = repository;
this.prefix = options.prefix || 'cache';
this.defaultTTL = options.ttl || 3600;
}
key(method, ...args) {
return `${this.prefix}:${method}:${JSON.stringify(args)}`;
}
async wrap(method, ...args) {
const cacheKey = this.key(method, ...args);
// Try cache first
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Call repository method
const result = await this.repository[method](...args);
// Cache result
if (result !== null && result !== undefined) {
await this.redis.setex(
cacheKey,
this.defaultTTL,
JSON.stringify(result)
);
}
return result;
}
async invalidate(method, ...args) {
const cacheKey = this.key(method, ...args);
await this.redis.del(cacheKey);
}
async invalidateAll() {
const keys = await this.redis.keys(`${this.prefix}:*`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}
// Usage
const userCache = new ReadThroughCache(redis, UserRepository, {
prefix: 'users',
ttl: 1800
});
const user = await userCache.wrap('findById', userId);
const users = await userCache.wrap('findByRole', 'admin');
// Invalidate after update
await userCache.invalidate('findById', userId);
Practice
Exercise 1: Multi-Level Cache
Difficulty: Intermediate
Implement a cache that:
- Checks memory first
- Falls back to Redis
- Updates both on miss
Exercise 2: Cache Warming
Difficulty: Advanced
Build a cache warming system:
- Pre-populate on deploy
- Background refresh
- Priority-based warming
Summary
What you learned:
- HTTP caching headers
- Redis caching patterns
- Cache invalidation strategies
- CDN and edge caching
- Tag-based invalidation
Next Steps:
- Read: Performance Optimization
- Practice: Add caching to your API
- Explore: CDN configuration
