Caching Strategies and Tools: Redis, Memcached, CDN, and Application-Level Patterns
Caching Strategies and Tools: Redis, Memcached, CDN, and Application-Level Patterns
Caching is the single most effective tool for improving application performance. A well-placed cache can turn a 200ms database query into a 1ms lookup. But caching also introduces complexity: stale data, cache stampedes, invalidation bugs, and cold-start problems. This guide covers the full caching stack, from browser to CDN to application to data store, with practical patterns that actually work in production.
The Caching Stack
Most production applications use multiple caching layers working together:
Browser Cache (client)
↓ miss
CDN / Edge Cache (Cloudflare, Fastly)
↓ miss
Reverse Proxy Cache (Nginx, Varnish)
↓ miss
Application Cache (in-memory, Redis)
↓ miss
Database Query Cache / Materialized Views
↓ miss
Origin Database
Each layer has different tradeoffs. The closer to the user, the faster the response but the harder to invalidate.
Browser Caching
Browser caching is free performance. The Cache-Control header controls everything.
# Cache static assets aggressively (1 year)
Cache-Control: public, max-age=31536000, immutable
# Cache API responses briefly (60 seconds)
Cache-Control: private, max-age=60
# Don't cache at all (authentication endpoints, user-specific data)
Cache-Control: no-store
# Revalidate every time (good for HTML pages)
Cache-Control: no-cache
The most common setup for modern apps:
- Static assets (JS, CSS, images with hashed filenames):
max-age=31536000, immutable. The hash in the filename handles "invalidation" since a new deploy produces new URLs. - HTML pages:
no-cacheormax-age=0, must-revalidateso the browser always checks for updates but can use a cached copy if the server returns304 Not Modified. - API responses:
private, max-age=60orno-storedepending on how fresh data needs to be.
ETag and Conditional Requests
ETags let the browser ask "has this changed?" without downloading the full response:
// Express middleware for ETag-based caching
import { createHash } from "crypto";
app.get("/api/products/:id", async (req, res) => {
const product = await db.products.findById(req.params.id);
const etag = createHash("md5")
.update(JSON.stringify(product))
.digest("hex");
res.set("ETag", `"${etag}"`);
res.set("Cache-Control", "private, max-age=0, must-revalidate");
if (req.headers["if-none-match"] === `"${etag}"`) {
return res.status(304).end();
}
res.json(product);
});
CDN Caching
A CDN caches your content at edge locations worldwide. For static sites and assets, this is transformative. For APIs, it requires careful configuration.
Cloudflare Cache Rules
# Cache API responses at the edge for 60 seconds
# Cloudflare dashboard: Caching > Cache Rules
Match: hostname eq "api.example.com" and URI path starts with "/v1/public/"
Then: Cache eligible, Edge TTL: 60s, Browser TTL: 30s
# Bypass cache for authenticated requests
Match: hostname eq "api.example.com" and any(http.request.headers["authorization"][*] ne "")
Then: Bypass cache
Surrogate Keys (Cache Tags)
The best CDN caching pattern uses cache tags for surgical invalidation:
// Tag responses with the resources they contain
app.get("/api/products", async (req, res) => {
const products = await db.products.list();
res.set("Cache-Tag", products.map(p => `product-${p.id}`).join(","));
res.set("CDN-Cache-Control", "max-age=3600");
res.json(products);
});
// When a product updates, purge just that tag
async function onProductUpdate(productId: string) {
await fetch("https://api.fastly.com/service/{id}/purge/product-" + productId, {
method: "POST",
headers: { "Fastly-Key": process.env.FASTLY_API_KEY },
});
}
This lets you cache aggressively (1-hour TTL) while still invalidating within seconds when data changes.
Application-Level Caching with Redis
Redis is the standard for application-level caching. It's fast (sub-millisecond reads), supports rich data structures, and has built-in expiration.
Local Dev Setup
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
The allkeys-lru eviction policy means Redis automatically evicts the least recently used keys when memory is full. This is usually what you want for a cache.
Cache-Aside Pattern
The most common caching pattern. The application checks the cache first, falls back to the database on miss, and populates the cache for next time.
import Redis from "ioredis";
const redis = new Redis();
const CACHE_TTL = 300; // 5 minutes
async function getProduct(id: string) {
// 1. Check cache
const cached = await redis.get(`product:${id}`);
if (cached) {
return JSON.parse(cached);
}
// 2. Cache miss — fetch from database
const product = await db.products.findById(id);
if (!product) return null;
// 3. Populate cache
await redis.set(`product:${id}`, JSON.stringify(product), "EX", CACHE_TTL);
return product;
}
// Invalidate on write
async function updateProduct(id: string, data: Partial<Product>) {
await db.products.update(id, data);
await redis.del(`product:${id}`);
}
Cache Stampede Prevention
When a popular cache key expires, many concurrent requests can hit the database simultaneously. This is a cache stampede. Prevent it with a lock:
async function getProductSafe(id: string) {
const cacheKey = `product:${id}`;
const lockKey = `lock:${cacheKey}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Try to acquire a lock (expires in 5 seconds)
const acquired = await redis.set(lockKey, "1", "EX", 5, "NX");
if (acquired) {
// We got the lock — fetch and cache
const product = await db.products.findById(id);
await redis.set(cacheKey, JSON.stringify(product), "EX", CACHE_TTL);
await redis.del(lockKey);
return product;
}
// Another request is fetching — wait briefly and retry
await new Promise((r) => setTimeout(r, 100));
return getProductSafe(id);
}
Write-Through Pattern
For data that's read much more often than it's written, you can update the cache at write time instead of invalidating:
async function updateProduct(id: string, data: Partial<Product>) {
const product = await db.products.update(id, data);
// Update cache immediately with the fresh data
await redis.set(`product:${id}`, JSON.stringify(product), "EX", CACHE_TTL);
return product;
}
This avoids the cache-miss penalty after every write but introduces a risk of cache-database inconsistency if the cache write fails.
Redis vs Memcached
| Feature | Redis | Memcached |
|---|---|---|
| Data structures | Strings, hashes, lists, sets, sorted sets, streams | Strings only |
| Persistence | Optional (RDB, AOF) | None |
| Eviction policies | 8 policies (LRU, LFU, random, TTL-based) | LRU only |
| Max value size | 512 MB | 1 MB (default) |
| Threading | Single-threaded (multi-threaded I/O in Redis 7) | Multi-threaded |
| Clustering | Redis Cluster | Client-side sharding |
| Pub/Sub | Yes | No |
| Lua scripting | Yes | No |
Pick Redis unless you have a specific reason not to. It does everything Memcached does and much more. Memcached's only real advantage is slightly better multi-threaded performance for simple get/set workloads with very high concurrency, and even that edge has narrowed with Redis 7's multi-threaded I/O.
Pick Memcached if you're caching large numbers of simple key-value pairs and need to squeeze out every microsecond, or if you're already running it and it's working.
Application-Level Patterns
Memoization (In-Process Cache)
For data that changes rarely and is expensive to compute, an in-process cache avoids the network round-trip to Redis entirely.
import { LRUCache } from "lru-cache";
const configCache = new LRUCache<string, any>({
max: 500, // max entries
ttl: 1000 * 60 * 5, // 5 minutes
});
async function getConfig(key: string) {
const cached = configCache.get(key);
if (cached !== undefined) return cached;
const value = await db.config.get(key);
configCache.set(key, value);
return value;
}
Warning: In-process caches don't share state between server instances. If you have 10 servers, each has its own cache, which means 10x memory usage and potential inconsistency. Use in-process caching for truly static data (feature flags, config) and Redis for everything else.
Stale-While-Revalidate
Serve stale data immediately while refreshing in the background. This gives you the consistency of short TTLs with the latency of long ones.
async function getProductSWR(id: string) {
const cacheKey = `product:${id}`;
const cached = await redis.get(cacheKey);
if (cached) {
const { data, fetchedAt } = JSON.parse(cached);
const age = Date.now() - fetchedAt;
if (age < 60_000) {
// Fresh (under 1 minute) — return as-is
return data;
}
if (age < 300_000) {
// Stale but usable (1-5 minutes) — return stale, refresh in background
refreshInBackground(id, cacheKey);
return data;
}
}
// Cache miss or too stale — fetch synchronously
return fetchAndCache(id, cacheKey);
}
async function refreshInBackground(id: string, cacheKey: string) {
// Fire and forget — don't await this
fetchAndCache(id, cacheKey).catch(console.error);
}
async function fetchAndCache(id: string, cacheKey: string) {
const data = await db.products.findById(id);
await redis.set(cacheKey, JSON.stringify({ data, fetchedAt: Date.now() }), "EX", 600);
return data;
}
Cache Invalidation Strategies
Cache invalidation is famously one of the two hard problems in computer science. Here are the practical approaches:
- TTL-based: Set an expiration and accept some staleness. Simplest and most reliable.
- Event-driven: Invalidate on write using application events or database triggers.
- Tag-based: Associate cache entries with tags and purge by tag (best for CDN caching).
- Version-based: Include a version number in the cache key. Increment the version to invalidate all entries.
The right approach depends on your consistency requirements. Most applications can tolerate 30-60 seconds of staleness, which means TTL-based caching with a short TTL is enough. For financial data or inventory counts, use event-driven invalidation.
Monitoring Your Cache
A cache you can't observe is a cache you can't trust. Track these metrics:
- Hit rate: Should be >90% for a well-tuned cache. Below 80% means you're caching the wrong things or your TTLs are too short.
- Eviction rate: High evictions mean you need more memory or better eviction policies.
- Latency: Redis should respond in <1ms. If it's higher, check network or memory pressure.
- Memory usage: Track
used_memoryvsmaxmemoryin Redis.
# Redis cache stats
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
redis-cli INFO memory | grep used_memory_human
Start with the simplest caching layer that solves your problem. Browser caching and CDN caching are almost free. Application-level caching with Redis is the next step. Only add more complexity (write-through, stampede prevention, stale-while-revalidate) when you have evidence that you need it.