Caching Strategies for Web Applications
The product page that loaded fine in staging starts taking two seconds once real traffic shows up. You open the slow query log and find the same handful of queries running thousands of times a minute, returning the exact same rows. That's usually the moment someone says "let's add Redis," and it's also where a lot of stale-data bugs are born.
This post goes through each caching layer, from browser and CDN caching down to Redis and Memcached, with code for Laravel, Node.js and a few others. Most of the attention goes to the part that actually causes trouble: deciding when cached data stops being valid.
- The caching pyramid: browser, CDN, application, and database caching
- Redis vs Memcached: choosing the right in-memory store
- HTTP caching headers: Cache-Control, ETag, and Last-Modified
- Cache invalidation strategies that actually work
- Implementing caching in Laravel, Node.js, and React
- Common caching pitfalls and how to avoid them
- Measuring and optimizing cache hit rates
The Caching Pyramid
A single request can pass through four or five caches before it reaches your database. Each one has its own rules about how long it keeps data, and when a user sees something old, you need to know which layer is holding it.
Each cache layer is faster than the one below it. The goal is to serve as many requests as possible from the upper layers.
Browser Cache
Stores assets locally in the user's browser. Zero network latency, but limited to individual users.
CDN Cache
Distributes cached content across edge servers worldwide. Great for static assets and API responses.
Application Cache
In-memory stores like Redis handle dynamic data, sessions, and computed results.
Database Cache
Buffer pools keep hot pages in memory so repeated reads skip the disk. Note that MySQL 8.0 removed the query cache entirely, so don't count on it.
Redis vs Memcached
For application-level caching, most teams end up picking between these two. Both keep everything in memory and answer in well under a millisecond on a local network. The difference is in what else they can do.
| Feature | Redis | Memcached |
|---|---|---|
| Data Structures | Strings, Lists, Sets, Hashes, Sorted Sets, Streams | Strings only |
| Persistence | ✅ RDB snapshots, AOF logs | ❌ None (pure cache) |
| Pub/Sub | ✅ Built-in messaging | ❌ Not supported |
| Clustering | ✅ Redis Cluster with auto-sharding | ✅ Client-side sharding |
| Memory Efficiency | Good (slightly higher overhead) | Excellent (minimal overhead) |
| Best For | Sessions, queues, leaderboards, real-time features | Simple key-value caching at scale |
Choose Redis if: You need data persistence, pub/sub, complex data structures, or Lua scripting.
Choose Memcached if: You only need a plain key-value cache and want the lowest memory overhead per item.
If you're on Laravel, there's one more thing: Cache::tags() doesn't work with the file or database drivers. You'll get "This cache store does not support tagging." The tags example below needs Redis or Memcached.
Redis Implementation Examples
import Redis from 'ioredis';
const redis = new Redis({
host: 'localhost',
port: 6379,
maxRetriesPerRequest: 3
});
// Cache-aside pattern implementation
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// 1. Try cache first
const cached = await redis.get(cacheKey);
if (cached) {
console.log('Cache HIT');
return JSON.parse(cached);
}
// 2. Cache miss - fetch from database
console.log('Cache MISS');
const user = await db.users.findById(userId);
// 3. Store in cache with 1 hour TTL
await redis.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
// Cache invalidation on update
async function updateUser(userId, data) {
await db.users.update(userId, data);
await redis.del(`user:${userId}`); // Invalidate cache
}
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class UserController extends Controller
{
public function show($id)
{
// Remember pattern - cache for 1 hour
$user = Cache::remember("user:{$id}", 3600, function () use ($id) {
return User::with('profile', 'posts')->findOrFail($id);
});
return response()->json($user);
}
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
$user->update($request->validated());
// Invalidate cache
Cache::forget("user:{$id}");
// Also invalidate any list caches
Cache::tags(['users'])->flush();
return response()->json($user);
}
// Cache with tags for easier invalidation
public function index()
{
return Cache::tags(['users'])->remember('users:all', 600, function () {
return User::paginate(20);
});
}
}
import redis
import json
from functools import wraps
r = redis.Redis(host='localhost', port=6379, db=0)
def cached(ttl=3600, prefix='cache'):
"""Decorator for caching function results"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Generate cache key from function name and arguments
key = f"{prefix}:{func.__name__}:{hash(str(args) + str(kwargs))}"
# Try to get from cache
cached_result = r.get(key)
if cached_result:
return json.loads(cached_result)
# Execute function and cache result
result = func(*args, **kwargs)
r.setex(key, ttl, json.dumps(result))
return result
return wrapper
return decorator
@cached(ttl=1800, prefix='api')
def get_user_stats(user_id: int) -> dict:
"""Expensive computation cached for 30 minutes"""
# Simulate expensive database queries
return {
'user_id': user_id,
'total_orders': calculate_orders(user_id),
'lifetime_value': calculate_ltv(user_id),
'engagement_score': calculate_engagement(user_id)
}
HTTP Caching Headers
The browser and any CDN in front of you decide what to cache based on the response headers you send. Get them wrong and you either cache nothing or cache something you really shouldn't.
ETags and conditional requests enable efficient cache revalidation without transferring the full response
Cache-Control Directives
# Cache for 1 hour in browser and shared caches (CDN)
Cache-Control: public, max-age=3600
# Cache for 1 day, but revalidate after 1 hour
Cache-Control: public, max-age=86400, stale-while-revalidate=3600
# Private cache only (not CDN), requires revalidation
Cache-Control: private, no-cache
# Never cache (sensitive data)
Cache-Control: no-store
# Immutable - never revalidate (for versioned assets)
Cache-Control: public, max-age=31536000, immutable
no-cache doesn't mean "don't cache"! It means "cache, but always revalidate before using." Use no-store to completely prevent caching.
Setting Headers in Your Application
import express from 'express';
import crypto from 'crypto';
const app = express();
// Static assets - long cache with versioning
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true,
etag: true
}));
// API endpoint with ETag support
app.get('/api/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
// Generate ETag from content
const etag = crypto
.createHash('md5')
.update(JSON.stringify(product))
.digest('hex');
// Check if client has current version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'Cache-Control': 'private, max-age=60',
'ETag': etag
});
res.json(product);
});
server {
# Static assets with long cache
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
}
# Images with medium cache
location ~* \.(jpg|jpeg|png|gif|webp|svg|ico)$ {
expires 30d;
add_header Cache-Control "public";
}
# API responses - short cache with revalidation
location /api/ {
add_header Cache-Control "private, max-age=60, stale-while-revalidate=300";
add_header Vary "Authorization, Accept";
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 1m;
proxy_cache_use_stale error timeout updating;
}
# HTML - no cache (always fresh)
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
}
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const product = await getProduct(params.id);
// Generate ETag
const etag = generateETag(product);
// Check conditional request
const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch === etag) {
return new Response(null, { status: 304 });
}
return NextResponse.json(product, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
'ETag': etag,
'Vary': 'Accept-Encoding'
}
});
}
// For static generation with ISR
export const revalidate = 60; // Revalidate every 60 seconds
Cache Invalidation Strategies
As Phil Karlton famously said: "There are only two hard things in Computer Science: cache invalidation and naming things." It's a joke, but anyone who's chased a stale price on a checkout page knows it's only half a joke. Here are the approaches I see used most.
Choose your invalidation strategy based on data freshness requirements and system complexity
Event-Based Invalidation with Redis Pub/Sub
import Redis from 'ioredis';
const publisher = new Redis();
const subscriber = new Redis();
// Publisher: When data changes
async function updateProduct(productId, data) {
// 1. Update database
await db.products.update(productId, data);
// 2. Publish invalidation event
await publisher.publish('cache:invalidate', JSON.stringify({
type: 'product',
id: productId,
keys: [
`product:${productId}`,
`product:${productId}:details`,
'products:list',
`category:${data.categoryId}:products`
]
}));
}
// Subscriber: Listen for invalidation events
subscriber.subscribe('cache:invalidate');
subscriber.on('message', async (channel, message) => {
const { type, id, keys } = JSON.parse(message);
console.log(`Invalidating cache for ${type}:${id}`);
// Delete all related cache keys
if (keys.length > 0) {
await redis.del(...keys);
}
// Optionally warm the cache immediately
if (type === 'product') {
await warmProductCache(id);
}
});
// Cache warming function
async function warmProductCache(productId) {
const product = await db.products.findById(productId);
await redis.setex(`product:${productId}`, 3600, JSON.stringify(product));
}
Cache Tags Pattern (Laravel-style)
class TaggedCache {
constructor(redis) {
this.redis = redis;
}
// Store value with tags
async set(key, value, ttl, tags = []) {
const pipeline = this.redis.pipeline();
// Store the actual value
pipeline.setex(key, ttl, JSON.stringify(value));
// Associate key with each tag
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttl + 60); // Tags expire slightly later
}
await pipeline.exec();
}
// Invalidate all keys with a specific tag
async invalidateTag(tag) {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
await this.redis.del(...keys);
await this.redis.del(`tag:${tag}`);
}
return keys.length;
}
}
// Usage
const cache = new TaggedCache(redis);
// Store product with multiple tags
await cache.set(
`product:${product.id}`,
product,
3600,
['products', `category:${product.categoryId}`, `brand:${product.brandId}`]
);
// When a category is updated, invalidate all its products
await cache.invalidateTag(`category:${categoryId}`);
// When inventory changes, invalidate all products
await cache.invalidateTag('products');
CDN Caching Strategies
A CDN keeps copies of your responses on servers close to your users, so a visitor in Sydney doesn't wait on a round trip to your origin in Frankfurt. The hard part is telling it what's safe to share between users.
CDN Cache Best Practices
- Static Assets: Use immutable caching with content hashing (
/app.a1b2c3.js) - API Responses: Use
s-maxagefor CDN-specific TTL separate from browser cache - Personalized Content: Use
Varyheader or bypass CDN cache entirely - Cache Purging: Implement instant purge APIs for emergency updates
- Stale-While-Revalidate: Serve stale content while fetching fresh in background
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Check edge cache first
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
// Cache miss - fetch from origin
response = await fetch(request);
// Clone response for caching
response = new Response(response.body, response);
// Set caching headers based on content type
if (url.pathname.startsWith('/api/')) {
response.headers.set('Cache-Control', 's-maxage=60, stale-while-revalidate=300');
} else if (url.pathname.match(/\.(js|css|woff2)$/)) {
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
}
// Store in edge cache (non-blocking)
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
}
};
Measuring Cache Performance
A cache you never look at can quietly sit at a 20% hit rate while you pay for the memory. These are the numbers I'd watch.
Cache Hit Rate
Share of requests served from cache. Static assets should be close to everything; dynamic data will be lower and depends heavily on your TTLs.
Response Time
Compare cached vs uncached response times. If a hit isn't clearly faster than a miss, the cache isn't earning its keep.
Time-To-First-Byte
Measures server response latency. A CDN hit should show a visibly lower TTFB than going to origin, especially for distant users.
Memory Usage
Monitor Redis/Memcached memory. Set eviction policies before hitting limits.
# Check cache statistics
redis-cli INFO stats
# Key metrics to monitor:
# - keyspace_hits: Number of successful key lookups
# - keyspace_misses: Number of failed key lookups
# - evicted_keys: Keys removed due to maxmemory policy
# Calculate hit rate
redis-cli INFO stats | grep keyspace
# Hit Rate = hits / (hits + misses) * 100
# Monitor memory usage
redis-cli INFO memory
# used_memory_human: Current memory usage
# maxmemory_human: Memory limit
# Check slowlog for slow operations
redis-cli SLOWLOG GET 10
Prometheus Metrics for Cache Monitoring
import { Counter, Histogram, Gauge } from 'prom-client';
// Cache hit/miss counter
const cacheHits = new Counter({
name: 'cache_hits_total',
help: 'Total number of cache hits',
labelNames: ['cache_type', 'key_prefix']
});
const cacheMisses = new Counter({
name: 'cache_misses_total',
help: 'Total number of cache misses',
labelNames: ['cache_type', 'key_prefix']
});
// Cache operation latency
const cacheLatency = new Histogram({
name: 'cache_operation_duration_seconds',
help: 'Cache operation latency in seconds',
labelNames: ['operation', 'cache_type'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5]
});
// Memory usage gauge
const cacheMemory = new Gauge({
name: 'cache_memory_bytes',
help: 'Current cache memory usage in bytes'
});
// Instrumented cache wrapper
class InstrumentedCache {
constructor(redis) {
this.redis = redis;
}
async get(key) {
const timer = cacheLatency.startTimer({ operation: 'get', cache_type: 'redis' });
const prefix = key.split(':')[0];
try {
const value = await this.redis.get(key);
if (value) {
cacheHits.inc({ cache_type: 'redis', key_prefix: prefix });
} else {
cacheMisses.inc({ cache_type: 'redis', key_prefix: prefix });
}
return value ? JSON.parse(value) : null;
} finally {
timer();
}
}
}
Common Caching Pitfalls
None of these show up in local testing. They appear under real traffic, usually at a bad time.
Cache Stampede (Thundering Herd)
When a popular cache key expires, hundreds of requests simultaneously hit the database. Solution: Use locking (single-flight) or stale-while-revalidate patterns.
Caching Nulls Without TTL
Caching "not found" results permanently can hide data that's added later. Solution: Use short TTLs for negative caching (30-60 seconds).
Inconsistent Cache Keys
Generating different cache keys for the same data leads to duplicate caching and stale data. Solution: Centralize cache key generation and normalize inputs.
Over-Caching User-Specific Data
Accidentally serving one user's cached data to another. Solution: Include user ID in cache keys or use Cache-Control: private.
Never cache responses based on unvalidated user input. An attacker could poison your cache with malicious content that gets served to all users. Always sanitize cache keys and validate that cached responses are safe to serve.
Preventing Cache Stampede
// Single-flight pattern: Only one request fetches, others wait
const inFlight = new Map();
async function getWithSingleFlight(key, fetchFn, ttl = 3600) {
// Check cache first
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Check if another request is already fetching
if (inFlight.has(key)) {
return inFlight.get(key);
}
// Create promise for this fetch
const fetchPromise = (async () => {
try {
const data = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
} finally {
inFlight.delete(key);
}
})();
inFlight.set(key, fetchPromise);
return fetchPromise;
}
// With Redis locking for distributed systems
async function getWithLock(key, fetchFn, ttl = 3600) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockTTL = 10; // 10 second lock
// Try to acquire lock
const acquired = await redis.set(lockKey, '1', 'EX', lockTTL, 'NX');
if (acquired) {
try {
const data = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
} finally {
await redis.del(lockKey);
}
} else {
// Wait and retry
await new Promise(r => setTimeout(r, 100));
return getWithLock(key, fetchFn, ttl);
}
}
Caching Checklist
Before a cache goes to production, I go through this list. Most of the items come from things that are easy to forget until they break.
Pre-Launch Caching Checklist
- ☐ Static assets have content-based hashes and immutable headers
- ☐ API responses have appropriate Cache-Control headers
- ☐ Redis/Memcached has memory limits and eviction policy configured
- ☐ Cache invalidation is triggered on all data mutation paths
- ☐ Sensitive data uses
Cache-Control: privateorno-store - ☐ Cache keys include version/environment prefix
- ☐ Monitoring for cache hit rate, memory usage, and latency
- ☐ Cache stampede protection in place for hot keys
- ☐ CDN purge mechanism tested and documented
- ☐ Fallback behavior when cache is unavailable
When I'd add a cache (and when I wouldn't)
I reach for Redis when the same expensive read happens over and over and the data can be a little old without anyone noticing: product catalogs, dashboard aggregates, config, rendered fragments. For static assets, hashed filenames plus immutable headers is nearly free and I'd do it on every project.
I'd hold off when:
- The slow query hasn't been fixed yet. A missing index is cheaper to add than a cache layer, and caching hides it until the first cold start.
- The data has to be exact. Account balances, stock counts during checkout and permissions are places where a stale read is a bug, not a trade-off.
- You can't name every write path. If updates come from admin panels, queue workers and a cron job, and you can only invalidate from one of them, you'll serve stale data.
- Traffic is low. A database answering a few requests a second doesn't need help, and you've added a moving part to monitor.
Whatever you add, make sure the app still works (slower) when Redis is down, and start with plain TTLs before building event-driven invalidation. The setup you can still debug at 3 AM is better than a clever one you can't.