Caching Strategies: Browser, CDN, Redis & Database
Implement effective caching at every layer of the stack: browser cache, CDN edge, Redis application cache, and database query cache — with invalidation strategies that keep data fresh.
Overview
Caching is the single most effective technique for improving application performance. Storing frequently accessed data closer to consumers — in browser memory, CDN edges, or in-memory stores — cuts latency and database load. Every layer of the stack can cache, and each layer has different latency, capacity, and invalidation mechanics. This recipe covers the main strategies with working implementations, from browser cache headers down to database buffer pools.
Choosing the right strategy depends on two things: how fresh the data must be, and whether the workload reads or writes more. Get both wrong and the cache becomes a liability — stale data, stampedes, and memory bloat.
When to Use
Use this resource when:
- Database queries are becoming a bottleneck under load
- API response times exceed 200ms for read-heavy endpoints
- Serving static assets (images, JS, CSS) to global users via CDN
- Building high-traffic applications where slightly stale data is acceptable
- Running expensive computations or repeated identical queries whose results can be reused
The Caching Hierarchy
A request passes through several cache layers before reaching the database. Each layer it resolves at saves an order of magnitude in latency.
| Layer | Technology | Latency | What it caches |
|---|---|---|---|
| Browser | Cache-Control, Service Worker, LocalStorage | ~0-1ms | Static assets, API responses, offline-first apps |
| CDN | CloudFront, Cloudflare, Fastly | ~10-50ms | Images, CSS, JS, edge-cached API responses |
| Reverse proxy | Nginx proxy_cache, Varnish | ~1-5ms | API endpoints, rendered pages |
| Application | Redis, Memcached | ~1ms | Session data, hot keys, computed results |
| Database | Buffer pool, materialized views | ~1-10ms | Repeated complex queries, aggregations |
A cache that resolves at the browser costs you nothing. A cache miss that reaches the database costs the most. Design your key and TTL strategy so most traffic resolves as early in the chain as the data allows.
Solution
Browser Caching
Use the browser’s built-in cache first. Long-lived Cache-Control on hashed static assets eliminates repeat downloads entirely; a Service Worker adds offline support.
# Nginx: aggressive caching for fingerprinted static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
}
# API responses: conditional caching
location /api/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
// Service Worker: offline-first caching (sw.js)
const CACHE_NAME = 'app-v1';
const urlsToCache = ['/static/app.js', '/static/styles.css', '/api/config'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
| Header | Meaning | Use for |
|---|---|---|
Cache-Control: no-store | Never cache | Sensitive data |
Cache-Control: no-cache | Revalidate every request | Semi-live content |
Cache-Control: max-age=3600 | Cache for 1 hour | Semi-static API responses |
Cache-Control: immutable | Never revalidate | Fingerprinted asset filenames |
ETag / Last-Modified | Conditional-request validators | Resources that change occasionally |
CDN and Reverse Proxy Caching
Cache at the edge and at your own proxy to keep traffic off the origin. Nginx proxy_cache gives you an edge-like layer even without a commercial CDN.
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:100m max_size=1g;
server {
location /api/public/ {
proxy_cache app_cache;
proxy_cache_valid 200 5m;
proxy_cache_use_stale error timeout updating;
proxy_cache_key "$scheme$request_method$host$request_uri";
add_header X-Cache-Status $upstream_cache_status;
}
}
{
"routes": [
{
"src": "/api/public/.*",
"headers": {
"Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400"
}
}
]
}
s-maxage controls shared caches (CDN, proxies) while max-age controls browsers. proxy_cache_use_stale serves an expired response while the origin is down or slow — the same idea as stale-while-revalidate, implemented server-side.
Redis Cache-Aside (Node.js)
The workhorse pattern: check the cache, fall back to the database, populate on miss.
const redis = require('redis');
const client = redis.createClient();
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// Try cache first
const cached = await client.get(cacheKey);
if (cached) return JSON.parse(cached);
// Cache miss: query database
const user = await db.users.findById(userId);
if (user) {
await client.setEx(cacheKey, 3600, JSON.stringify(user)); // TTL 1 hour
}
return user;
}
The equivalent in Spring Boot is declarative — @Cacheable reads, @CacheEvict invalidates:
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product getProduct(String id) {
return productRepository.findById(id).orElseThrow();
}
@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
}
Database Query Caching
Let the database help. Buffer pool tuning keeps hot pages in RAM; materialized views precompute expensive aggregations.
-- postgresql.conf: keep hot data in the buffer pool
shared_buffers = 4GB -- ~25% of RAM
effective_cache_size = 12GB -- OS + PostgreSQL cache total
work_mem = 256MB -- per-query sort/hash memory
-- Precompute an expensive aggregation
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT
date_trunc('day', created_at) AS day,
sum(amount) AS total_sales,
count(*) AS order_count
FROM orders
WHERE created_at > now() - interval '90 days'
GROUP BY 1;
-- Refresh on a schedule, or via pg_cron
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales_summary;
MySQL removed its query cache in 8.0 — use ProxySQL or an application cache there. On PostgreSQL, aim for a buffer pool hit ratio above 99% on OLTP workloads.
Explanation
| Strategy | How it works | Best for | Risk |
|---|---|---|---|
| Cache-Aside | App checks cache, falls back to DB, populates on miss | Read-heavy; simple to implement | Stale data if invalidation fails |
| Read-Through | Cache proxies DB transparently | Read-heavy; library handles logic | Cache becomes a required dependency |
| Write-Through | Writes update cache and DB together | Strong consistency needed | Higher write latency |
| Write-Behind | Writes hit cache; DB flush is async | Write-heavy; eventual consistency | Data loss if cache dies before flush |
| Refresh-Ahead | Background refresh before expiry | Predictable access patterns | Wasted work on rarely-read keys |
Cache invalidation approaches:
| Approach | How it works | Best for |
|---|---|---|
| TTL (time-based) | Expire after a fixed duration | Data that tolerates brief staleness |
| Key-based | Version or hash embedded in the key | Immutable deploys, instant invalidation |
| Event-driven | Invalidate on data change via message bus | Distributed systems, strong freshness |
| Active invalidation | Delete or update the cache on write | Strong consistency requirements |
| Cache warming | Pre-populate before peak traffic | Predictable load, cold-start avoidance |
See cache invalidation for a dedicated walkthrough.
Variants
- Local in-process cache: Caffeine (Java),
lru_cache(Python), LRU-map (Node). Fastest option, no network hop, but per-instance — each process holds its own copy. - Distributed cache: Redis, Memcached. Shared across instances; adds a network hop but survives deploys and scales horizontally.
- Hierarchical cache: Local L1 plus Redis L2. Best of both worlds at the cost of more complex invalidation.
- Edge compute cache: Cloudflare Workers, Fastly Compute. Caches and computes at the edge; useful for personalization close to the user.
What Works
- Set TTLs based on data volatility: user profiles (1h), product catalogs (24h), prices (10s), sessions (15m)
- Stack your caches: browser + CDN + Redis + DB buffer pool, each with its own TTL
- Design for cache failure: if Redis goes down the app should degrade, not break — never let the cache be a hard dependency
- Monitor hit rates: below 80% on a hot key signals misconfiguration; target >90% for app cache, >99% for the DB buffer pool
- Version your cache keys: include the app version so a deploy invalidates everything at once
- Cap memory deliberately: set
maxmemoryand an eviction policy (allkeys-lruis a sane default) before Redis OOMs your host - Use consistent hashing for sharded caches: Redis Cluster or client-side sharding avoids hotspots
Common Mistakes
- Caching everything: cache static or slow-moving data; user-specific or rapidly changing data belongs out of shared caches
- No invalidation strategy: without TTLs or events, stale data persists indefinitely
- Thundering herd: a hot key expires and a thousand requests rebuild it at once — use locking, jitter, or SWR
- Cache poisoning: unvalidated user input written to a shared cache affects every user
- Ignoring cache warming: a cold cache after deploy causes latency spikes exactly when traffic is highest
- Caching non-serializable objects: store strings and JSON, not ORM objects or file handles — they deserialize into garbage
- No eviction policy: unbounded growth leads to OOM; set
maxmemory-policyexplicitly
Advanced: Cache Stampede Protection
const redis = require('redis');
const client = redis.createClient();
async function getWithLock(key, ttl, builder) {
const cached = await client.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `${key}:lock`;
const acquired = await client.set(lockKey, '1', { NX: true, EX: 10 });
if (!acquired) {
await new Promise(resolve => setTimeout(resolve, 100));
return getWithLock(key, ttl, builder);
}
try {
const value = await builder();
await client.setEx(key, ttl, JSON.stringify(value));
return value;
} finally {
await client.del(lockKey);
}
}
Redis SET NX makes only one request fetch from the database while others poll until the cache is populated. The lock expires after 10 seconds to prevent deadlocks if the builder crashes. For most workloads, stale-while-revalidate (below) is a simpler fix for the same problem.
Advanced: Multi-Level Caching
import redis
import hashlib
import json
r = redis.Redis()
def get_user(user_id):
# L1: Local in-memory cache (process-level)
if hasattr(get_user, '_cache') and user_id in get_user._cache:
return get_user._cache[user_id]
# L2: Redis shared cache
key = f'user:{user_id}'
cached = r.get(key)
if cached:
user = json.loads(cached)
if not hasattr(get_user, '_cache'):
get_user._cache = {}
get_user._cache[user_id] = user
return user
# L3: Database
user = db.users.find_by_id(user_id)
if user:
r.setex(key, 3600, json.dumps(user))
if not hasattr(get_user, '_cache'):
get_user._cache = {}
get_user._cache[user_id] = user
return user
L1 handles hot keys at sub-millisecond latency but only lives inside one process. L2 shares data across instances. L3 is the source of truth. Invalidate L1 on deploy by restarting the process or prefixing keys with a version — otherwise instances drift out of sync.
Advanced: Cache Key Design
def make_cache_key(resource, params, version='v1'):
param_hash = hashlib.md5(
json.dumps(params, sort_keys=True).encode()
).hexdigest()[:12]
return f'{version}:{resource}:{param_hash}'
Good cache keys are deterministic, versioned, and namespace-prefixed. The version segment lets you invalidate every key at once on deploy. Hashing parameter combinations keeps keys short and collision-resistant. Keep user IDs out of shared cache keys — use a separate namespace for per-user data.
Advanced: Event-Driven Invalidation
TTLs are a compromise: too long and data goes stale, too short and the hit rate drops. Publishing an invalidation event on write removes the trade-off for data that must be fresh.
import redis
r = redis.Redis(host='redis', port=6379)
p = r.pubsub()
def handle_invalidation(message):
key = message['data']
r.delete(f"cache:product:{key}")
p.subscribe(**{'product-updates': handle_invalidation})
p.run_in_thread(sleep_time=0.001)
# On product update, publish the event
r.publish('product-updates', product_id)
Every app instance subscribes and drops the affected key on update. For multi-layer setups, pair this with a version prefix so L1 caches expire on the next deploy even if an event is missed.
Advanced: CDN Invalidation
# CloudFront invalidation via AWS CLI
aws cloudfront create-invalidation \
--distribution-id E123ABC \
--paths "/*"
# Cloudflare purge via API
curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \
-H "Authorization: Bearer API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything": false, "files": ["https://example.com/api/products"]}'
Purge specific URLs when possible — full purges are slow, rate-limited, and cold-start every edge at once. For predictable content changes, use versioned URLs (/api/v2/products) instead of invalidating old paths, and set s-maxage so edges expire on their own.
Advanced: Stale-While-Revalidate Implementation
async function swr(key, ttl, builder) {
const cached = await client.get(key);
const staleFlag = await client.get(`${key}:stale`);
if (cached) {
const data = JSON.parse(cached);
// Stale: serve now, refresh in the background
if (staleFlag === '1') {
builder().then(value => {
client.setEx(key, ttl, JSON.stringify(value));
client.del(`${key}:stale`);
}).catch(err => console.error('SWR refresh failed:', err));
}
return data;
}
const value = await builder();
await client.setEx(key, ttl, JSON.stringify(value));
return value;
}
SWR serves cached data even after it goes stale, then refreshes in the background. Set the stale flag at 80% of TTL. No request ever waits for a rebuild, which eliminates stampedes at the cost of occasionally serving slightly old data.
Advanced: Cache Warming on Deploy
import redis
import asyncio
r = redis.Redis()
async def warm_cache(keys, builder):
tasks = []
for key in keys:
tasks.append(warm_single(key, builder))
await asyncio.gather(*tasks)
async def warm_single(key, builder):
value = await builder(key)
r.setex(key, 3600, json.dumps(value))
Cache warming pre-populates Redis before traffic arrives. Run it as a post-deploy step in CI/CD. Identify hot keys from analytics or redis-cli --hotkeys — warming the top 100-500 keys usually covers 80% of traffic and prevents the cold-cache latency spike that follows every restart.
When to Avoid
- Real-time data: trading prices, live scores, auction bids — staleness has a direct financial cost
- Compliance-sensitive data: HIPAA or GDPR contexts where cached copies can violate data-handling agreements
- Write-heavy workloads: the hit rate stays low because data changes before it expires
- Small datasets: if the whole dataset fits in process memory, a distributed cache adds complexity for nothing
Troubleshooting
- Hit rate stuck below 80%: check key design first — keys that embed timestamps or random IDs never hit. Then review TTLs against actual access patterns; a 60s TTL on hourly-read data guarantees misses.
- Redis memory keeps growing: run
redis-cli --bigkeysto find oversized values, confirmmaxmemory-policyis set (notnoevictionon a cache workload), and serialize values as compact JSON rather than language-specific pickles. - Stale data after every deploy: bump the version segment in your keys, or subscribe instances to an invalidation channel so writes propagate immediately.
- Latency spike right after restart or deploy: the cache is cold. Warm the top keys in a post-deploy step, or enable
proxy_cache_use_stale/ SWR so requests don’t queue on rebuilds. - One key getting hammered: a celebrity or hot-partition problem. Replicate the key (
key:1…key:N) and read a random replica, or keep it in L1 with a short TTL. - Different app instances serving different data: L1 caches drifted. Use version-prefixed keys, pub/sub invalidation, or short L1 TTLs so drift self-corrects.
See Also
- Companion code on GitHub: runnable versions of every example in this recipe — cache-aside, stampede lock, SWR, multi-level caching, pub/sub invalidation, Nginx config, Service Worker, and the materialized view.
- RFC 9111 — HTTP Caching: the normative spec behind
Cache-Control,ETag, and shared-cache semantics. - MDN — Cache-Control: directive-by-directive reference with browser behavior.
- Redis eviction policies: how
maxmemory-policyoptions differ and when each applies. - Cloudflare Cache documentation: edge cache rules, cache keys, and purge mechanics.
Frequently Asked Questions
How do I prevent cache stampedes?
Use a mutex or Redis SET NX so only one request rebuilds the cache while others wait. Alternatives: stagger TTLs with random jitter so keys don't expire together, or use stale-while-revalidate so no request ever blocks on a rebuild.
Should I cache GraphQL responses?
Yes, but cache by query hash plus variables — the same endpoint serves different payloads. Apollo Server supports cacheControl directives, and persisted queries can be cached by query ID.
What is the difference between Redis and Memcached?
Redis supports data structures (lists, sets, sorted sets), persistence, and pub/sub — which also makes it usable for event-driven invalidation. Memcached is simpler and marginally faster for plain key-value caching. Pick Redis unless you've got a specific reason not to.
Should I use write-behind caching?
Only if you can tolerate brief data loss and have a retry or reconciliation mechanism. A cache node dying before the async flush loses those writes. Write-through or cache-aside is safer for most applications.
When should I avoid caching?
When data changes fast and staleness is unacceptable — bank balances, inventory during a flash sale. Also skip it for low-traffic endpoints where the hit rate stays under 20%; the operational overhead outweighs the benefit.
How do I measure cache effectiveness?
Track hit rate, miss rate, eviction rate, and latency. Redis INFO stats exposes keyspace_hits and keyspace_misses. Aim for 80%+ on hot keys, and use redis-cli --bigkeys to spot keys eating disproportionate memory.
Related Resources
Web Performance Optimization Guide
A thorough guide to optimizing web application performance for better Core Web Vitals and user experience.
PatternProxy Pattern for API Response Caching
How to implement a caching proxy that intercepts API calls and stores responses to reduce latency and avoid redundant network requests
RecipeRedis Cache Patterns for High-Performance Applications
How to implement cache-aside, write-through, and write-behind patterns with Redis to reduce database load and improve response times
RecipeImplement Cache Invalidation Strategies
How to keep caches consistent with databases using TTL, write-through, write-behind, and event-driven invalidation patterns.
RecipeCDN Edge Caching: Cloudflare, CloudFront, Fastly
Configure edge caching rules on Cloudflare, AWS CloudFront, and Fastly: TTL policies, cache keys, purge and stale-while-revalidate.
GuideComplete Guide to Redis Caching Strategies
Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.