Caching Strategies: Redis, CDN, and Cache Invalidation

Caching Strategies: Redis, CDN, and Cache Invalidation
Caching is the single most impactful performance optimization available to most applications. A database query that takes 50ms costs 50ms every single time a user requests it. The same query result served from Redis costs 0.1ms. At 10,000 requests per second, that difference eliminates 499,000 milliseconds of database work per second.
But caching introduces the hardest problem in computer science: cache invalidation. When the underlying data changes, how do you make sure nobody reads the stale cached version?
This guide covers the full cache hierarchy, Redis patterns with code examples, all major invalidation strategies, CDN configuration, and the edge cases that cause cache bugs in production.
The Cache Hierarchy
A well-architected application uses multiple caching layers, each serving a different purpose:
User Request
↓
Browser Cache (0ms — data on user's machine)
↓ (cache miss)
CDN Edge Cache (5-20ms — data at nearest PoP)
↓ (cache miss)
Application Cache (0.1-1ms — Redis in same data center)
(Redis)
↓ (cache miss)
Database Query Cache (0.5-5ms — PostgreSQL shared_buffers)
↓ (cache miss)
Disk I/O (5-100ms — actual file read)Each layer serves different content types:
| Cache Layer | What it stores | Who controls it |
|---|---|---|
| Browser | HTML, CSS, JS, images | HTTP response headers |
| CDN | Static assets, API responses | CDN rules + HTTP headers |
| Redis | Database query results, sessions, computed data | Application code |
| PostgreSQL buffer pool | Table pages, index pages | Database configuration |
Redis: In-Memory Application Cache
Redis stores data in RAM. RAM access is approximately 100x faster than SSD access. For read-heavy workloads, Redis is the primary performance lever.
Setting Up Redis Caching
// lib/cache.js — Redis cache utility
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect();
export async function getCached(key, ttlSeconds, fetchFn) {
// Try cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Cache miss — fetch fresh data
const data = await fetchFn();
// Store in cache with TTL
await redis.setEx(key, ttlSeconds, JSON.stringify(data));
return data;
}
export async function invalidateCache(key) {
await redis.del(key);
}
export async function invalidateCachePattern(pattern) {
// Invalidate all keys matching a pattern
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(keys);
}
}Cache-Aside Pattern (Lazy Loading)
The most common caching pattern. The application checks the cache first; on a miss, it fetches from the database and populates the cache.
// routes/products.js
import { getCached, invalidateCache } from '../lib/cache.js';
// GET /products/:id
export async function getProduct(req, res) {
const { id } = req.params;
const cacheKey = `product:${id}`;
const product = await getCached(cacheKey, 3600, async () => {
// This only runs on a cache miss
return await db.query('SELECT * FROM products WHERE id = $1', [id]);
});
res.json(product);
}
// PUT /products/:id — invalidate cache on update
export async function updateProduct(req, res) {
const { id } = req.params;
await db.query(
'UPDATE products SET name = $1, price = $2 WHERE id = $3',
[req.body.name, req.body.price, id]
);
// Invalidate the cached version
await invalidateCache(`product:${id}`);
res.json({ success: true });
}Caching Database Query Results
// Cache a complex, expensive query
async function getTopSellingProducts(categoryId) {
const cacheKey = `top-products:${categoryId}`;
return getCached(cacheKey, 300, async () => {
// This query does multiple JOINs and aggregations — expensive
return await db.query(`
SELECT p.id, p.name, p.price, SUM(oi.quantity) as total_sold
FROM products p
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
WHERE p.category_id = $1
AND o.created_at > NOW() - INTERVAL '7 days'
GROUP BY p.id, p.name, p.price
ORDER BY total_sold DESC
LIMIT 10
`, [categoryId]);
});
}Session Storage in Redis
import session from 'express-session';
import { RedisStore } from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
maxAge: 86400000, // 24 hours
sameSite: 'strict',
},
}));Sessions stored in Redis survive server restarts and work across multiple server instances — essential for horizontal scaling.
Cache Invalidation Strategies
"There are only two hard things in computer science: cache invalidation and naming things." — Phil Karlton
Strategy 1: TTL (Time-To-Live)
Set a maximum age for cached data. After the TTL expires, the cache automatically returns a miss and the next request fetches fresh data.
// Cache product for 1 hour
await redis.setEx(`product:${id}`, 3600, JSON.stringify(product));
// Cache user profile for 5 minutes (changes more frequently)
await redis.setEx(`user:${userId}`, 300, JSON.stringify(userProfile));
// Cache homepage featured products for 15 minutes
await redis.setEx('homepage:featured', 900, JSON.stringify(featured));Best for: Read-heavy data that can tolerate slight staleness. Product descriptions, blog posts, configuration data.
Risk: Stale data window = TTL duration. If a price changes, users may see the old price for up to the TTL duration.
Strategy 2: Write-Through
Update the cache and the database simultaneously on every write.
async function updateProduct(id, data) {
// Update database first
const updated = await db.query(
'UPDATE products SET name = $1, price = $2 WHERE id = $3 RETURNING *',
[data.name, data.price, id]
);
// Immediately update cache with fresh data
await redis.setEx(
`product:${id}`,
3600,
JSON.stringify(updated.rows[0])
);
return updated.rows[0];
}Best for: Data that is written frequently and read immediately after writing. User profiles, account settings, inventory counts.
Downside: Every write hits both the database and Redis, adding latency to write operations.
Strategy 3: Write-Behind (Write-Back)
Update the cache immediately; write to the database asynchronously.
// Write to cache immediately — ultra-fast response
await redis.setEx(`product:${id}`, 3600, JSON.stringify(updatedProduct));
// Queue the database write for async processing
await queue.add('db-write', { table: 'products', id, data: updatedProduct });
// Background worker processes the queue
worker.process('db-write', async (job) => {
await db.query(
'UPDATE products SET name = $1, price = $2 WHERE id = $3',
[job.data.data.name, job.data.data.price, job.data.id]
);
});Best for: Very high-write scenarios where write latency is critical (gaming leaderboards, analytics event tracking, real-time counters).
Risk: If the server crashes between the cache write and the database write, data is permanently lost. Never use for financial or critical data.
Strategy 4: Cache-Invalidation on Write
Delete the cache entry when the underlying data changes. The next read repopulates it.
async function updateProduct(id, data) {
// Update database
await db.query(
'UPDATE products SET name = $1, price = $2 WHERE id = $3',
[data.name, data.price, id]
);
// Delete related cache entries
await redis.del(`product:${id}`);
await redis.del(`category-products:${data.categoryId}`);
await redis.del('homepage:featured'); // If this product appears on homepage
}Best for: Data with complex invalidation relationships where it is cheaper to delete than to recompute.
Downside: The first request after invalidation is slow (cache miss triggers a full database query). Under high traffic, multiple simultaneous misses for the same key can cause a "thundering herd" — all of them hitting the database simultaneously.
Thundering Herd Prevention
// Use a mutex to prevent multiple simultaneous cache misses
import Redlock from 'redlock';
const redlock = new Redlock([redis]);
async function getCachedWithMutex(key, ttl, fetchFn) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Acquire a lock so only one request fetches from the database
const lock = await redlock.acquire([`lock:${key}`], 5000);
try {
// Check cache again (another request may have populated it while we waited)
const cachedAfterLock = await redis.get(key);
if (cachedAfterLock) return JSON.parse(cachedAfterLock);
// Still a miss — fetch and populate
const data = await fetchFn();
await redis.setEx(key, ttl, JSON.stringify(data));
return data;
} finally {
await lock.release();
}
}CDN Caching
A CDN (Content Delivery Network) caches your content at Points of Presence (PoPs) around the world — edge nodes physically close to your users. A user in London hitting your CDN's London PoP experiences 5ms latency instead of 150ms round-trip to your US origin server.
HTTP Cache-Control Headers
CDN behavior is controlled by HTTP response headers:
// Express.js — set caching headers per route
app.use('/static', express.static('public', {
maxAge: '1y', // Browser and CDN cache for 1 year
immutable: true, // Content at this URL never changes (use with content hashing)
}));
// API endpoint — cache for 5 minutes at CDN, 1 minute in browser
app.get('/api/products', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=60, s-maxage=300',
// max-age: browser cache duration (60 seconds)
// s-maxage: CDN/shared cache duration (300 seconds, overrides max-age for CDNs)
});
res.json(products);
});
// Private user data — never cache at CDN
app.get('/api/user/profile', (req, res) => {
res.set('Cache-Control', 'private, no-store');
res.json(userProfile);
});Cache-Control Directives
| Directive | Meaning |
|---|---|
public | Can be cached by CDN and browsers |
private | Only browser cache, not CDN |
no-cache | Must revalidate with origin before serving |
no-store | Never cache anywhere |
max-age=N | Browser cache for N seconds |
s-maxage=N | CDN cache for N seconds (overrides max-age) |
immutable | Content will never change at this URL (skip revalidation) |
stale-while-revalidate=N | Serve stale while fetching fresh in background |
Stale-While-Revalidate: Best of Both Worlds
// Serve cached content immediately, revalidate in background
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=600');With this setting:
- For the first 60 seconds: serve fresh cached content immediately
- Seconds 60-660: serve the stale content immediately, but also fetch a fresh version in the background for the next request
- After 660 seconds: content is truly stale and must wait for a fresh fetch
The user never waits. The cache always has reasonably fresh data.
CDN Cache Purging
When you deploy a new version of your site, you need to invalidate CDN caches:
Strategy 1: Content-based cache busting (preferred)
Name files with a hash of their content. When the content changes, the filename changes, so the URL changes:
app.[hash].js → app.a1b2c3d4.js (cached forever)
app.[hash].js → app.e5f6g7h8.js (new file, new URL, cached forever)No manual CDN purge needed — the old URL still exists but is no longer referenced, and the new URL starts with a cold cache and gets cached immediately.
Strategy 2: Versioned URL paths
/api/v1/products → serve version 1
/api/v2/products → serve version 2Strategy 3: Manual CDN purge on deploy
# Cloudflare API — purge everything on deploy
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
# Or purge specific paths
--data '{"files":["https://example.com/products","https://example.com/api/products"]}'What Not to Cache
Some data should never be cached:
| Data | Reason not to cache |
|---|---|
| Real-time prices (stocks, crypto) | Changes every second |
| Inventory counts (especially near zero) | Overselling risk |
| Authentication tokens | Security — must not be stale |
| Shopping cart contents | User-specific, changes constantly |
| Financial balances | Must always be accurate |
| One-time use tokens (password reset links) | Must be invalidated on use |
Redis Data Structures for Advanced Caching
Redis offers more than simple key-value strings. Match the data structure to your use case:
// Sorted sets — real-time leaderboard
await redis.zAdd('leaderboard', [{ score: 1500, value: 'user:alice' }]);
const top10 = await redis.zRangeWithScores('leaderboard', 0, 9, { REV: true });
// Hash — cache an object without serializing
await redis.hSet('user:123', { name: 'Alice', email: 'alice@example.com', role: 'admin' });
const name = await redis.hGet('user:123', 'name'); // Read one field without loading the whole object
// Set — track unique visitors
await redis.sAdd('visitors:2026-04-18', userId);
const dailyVisitors = await redis.sCard('visitors:2026-04-18');
// Pub/Sub — real-time cache invalidation across servers
await redis.publish('cache-invalidation', JSON.stringify({ key: `product:${id}` }));
redis.subscribe('cache-invalidation', (message) => {
const { key } = JSON.parse(message);
localMemoryCache.delete(key); // Invalidate in-process cache
});Frequently Asked Questions
Q: Redis vs. Memcached — which should I choose?
Redis in 2026. Memcached is simpler but Redis offers persistence, pub/sub, Lua scripting, Streams, data structures (sorted sets, hashes, etc.), and a much richer feature set. The only advantage of Memcached is that it is slightly more memory-efficient for pure string caching at extreme scale. For the vast majority of use cases, choose Redis.
Q: Should I cache everything to make my app faster?
No. Caching adds complexity and stale data risk. Cache only data that is: (1) read significantly more often than it is written, and (2) acceptable to be slightly stale for the TTL duration. A product description is a great candidate. An account balance is not.
Q: How do I handle cache invalidation across multiple servers?
Use Redis pub/sub. When one server invalidates a cache key, it publishes a message. All servers subscribed to that channel invalidate their local in-process caches. This keeps distributed caches in sync without polling.
Q: What is the difference between max-age and s-maxage in Cache-Control?
max-age applies to all caches — browsers and CDNs. s-maxage applies only to shared caches (CDNs, proxy servers) and overrides max-age for those caches. Use s-maxage to set a longer CDN cache duration while keeping the browser cache shorter, giving you CDN performance benefits while still allowing browsers to pick up updates reasonably quickly.
Key Takeaway
Caching is layered: browser cache reduces round-trips, CDN cache reduces distance, Redis cache reduces database load. Each layer uses a different invalidation strategy. The golden rule is: cache only data that is read more than written and can tolerate a staleness window. Match your invalidation strategy to the data's consistency requirements — TTL for tolerant data, write-through for important data, and cache-busting URLs for static assets. Done correctly, caching is the difference between a site that handles 1,000 users and one that handles 1,000,000.
Read next: Database Sharding and Replication: Infinite Storage →
Part of the Software Architecture Hub — engineering the speed.
