Caching is the #1 performance lever you have. Not code splitting. Not lazy loading. Not switching to a faster framework. Caching. A single well-placed cache layer can turn a 2-second API call into a 5-millisecond response — and it works at every level of your stack.
The challenge is knowing where to cache, what strategy to use, and how to invalidate without serving stale data. This guide breaks down all four layers of caching, the strategies behind them, and the mistakes that trip up even experienced developers.
What Is Caching?
Caching is storing the result of an expensive operation so you can serve it again without repeating the work. The "expensive operation" could be a database query, an API call, a computation, or even rendering an entire page.
Think of it like a sticky note on your monitor. Instead of looking up your team's standup time every day, you check the sticky note. The information hasn't changed — so why look it up again?
Every caching system has the same core flow:
1. Request comes in 2. Check cache → HIT? Return cached result (fast path) 3. MISS? Execute the expensive operation 4. Store result in cache with TTL 5. Return result to caller
The key decisions are: what to cache, where to cache it, how long to keep it, and how to remove it when it's wrong. The rest of this guide covers exactly that.
The 4 Layers of Caching Explained
Caching isn't a single technique — it's a strategy applied at multiple levels. Each layer catches different types of redundant work, and they stack on top of each other.
1. Browser Cache
- What it is: The browser stores assets (JS, CSS, images, fonts) and API responses locally on the user's device.
- Why it matters: Eliminates network requests entirely. A cached asset loads in under 1ms from disk — no server contact needed.
- How it works: Controlled by
Cache-Controlheaders,ETag/Last-Modifiedfor conditional requests, and service workers for offline-first strategies. - TTL range: Seconds to years. Static assets with hashed filenames get
max-age=31536000(1 year). HTML pages typically useno-cachewith ETag validation. - Invalidation: Change the filename (content hash), update the ETag, or set
no-storeto bypass entirely.
2. CDN Cache
- What it is: Content Delivery Networks cache your responses at edge servers distributed globally — physically close to your users.
- Why it matters: Reduces latency from 200ms (cross-continent) to 20ms (nearby edge). Also shields your origin server from traffic spikes.
- How it works: CDN reads
Cache-Controlands-maxageheaders. Supportsstale-while-revalidatefor instant responses while refreshing in the background. - TTL range: Typically 60 seconds to 24 hours for dynamic content, up to 1 year for static assets.
- Invalidation: Purge by URL, purge by tag/surrogate-key, or wait for TTL expiry. Most CDNs (Cloudflare, Fastly, CloudFront) offer instant purge APIs.
3. Server / Application Cache
- What it is: Your application stores computed results in memory (process-level) or in an external store like Redis/Memcached.
- Why it matters: Avoids repeated database queries, API calls, or heavy computations. A Redis lookup takes ~0.5ms vs 50ms+ for a database query.
- How it works: Application checks cache before executing logic. In-memory caches use LRU eviction. Redis provides shared cache across multiple server instances with built-in TTL.
- TTL range: 1 second to 1 hour depending on data freshness requirements. User profiles might cache for 5 minutes; product catalogs for 1 hour.
- Invalidation: Event-driven (update cache when data changes), TTL expiry, or versioned keys (increment version to invalidate all related entries).
4. Database Cache
- What it is: Caching at the database level — either built-in query caches, materialized views, or an external Redis layer sitting in front of the database.
- Why it matters: Complex queries with JOINs across millions of rows can take seconds. Caching the result avoids re-executing expensive query plans.
- How it works: MySQL/PostgreSQL have internal query caches (though MySQL deprecated theirs). Materialized views precompute query results. Redis as read-aside stores serialized query results with TTL.
- TTL range: Seconds to hours. Materialized views refresh on schedule (every 5 minutes). Redis read-aside typically uses 30s–5min TTL.
- Invalidation: Refresh materialized views on write, delete Redis keys on data mutation, or use database triggers to clear related cache entries.
Comparison: All 4 Caching Layers
Here's how the layers stack up side by side:
| Layer | What It Caches | Typical TTL | Invalidation | Hit Ratio | Best For |
|---|---|---|---|---|---|
| Browser | Assets, API responses, pages | Minutes to 1 year | Filename hash, ETag, no-store | 90%+ for static | Repeat visits, static assets |
| CDN | HTML, API responses, media | 60s to 24h | Purge API, TTL, surrogate keys | 80-95% for popular content | Global latency, traffic spikes |
| Server/App | Computed results, API data | 1s to 1h | Event-driven, TTL, versioned keys | 70-90% depending on access patterns | Expensive computations, shared data |
| Database | Query results, aggregations | 30s to 1h | Refresh on write, triggers, TTL | 60-85% for read-heavy workloads | Complex queries, reporting |
Caching Strategies
Not all caches work the same way. The strategy determines who's responsible for populating and updating the cache. Here are the four main patterns:
Cache-Aside (Lazy Loading)
The application manages the cache directly. On read, it checks the cache first. On miss, it fetches from the database, stores the result in cache, then returns it. On write, the application invalidates (or updates) the cache entry.
// Cache-Aside pattern
async function getUser(userId) {
// 1. Check cache
const cached = await redis.get(`user:${userId}`)
if (cached) return JSON.parse(cached)
// 2. Cache miss — fetch from DB
const user = await db.users.findById(userId)
// 3. Populate cache with TTL
await redis.setex(`user:${userId}`, 300, JSON.stringify(user))
return user
}Most common pattern. You control exactly what gets cached and when.
Read-Through
The cache sits between the application and the database. On miss, the cache itself fetches from the database — the application doesn't need to know about the data source. Libraries like cacheable or AWS DAX implement this pattern.
Benefit: cleaner application code. Downside: less control over what happens on a miss.
Write-Through
Every write goes to the cache first, then the cache writes to the database synchronously. The cache is always up-to-date, so reads never see stale data. The trade-off is higher write latency since both stores must confirm.
Good for read-heavy workloads where consistency matters (user sessions, account balances).
Write-Behind (Write-Back)
Writes go to the cache immediately, but the cache flushes to the database asynchronously in batches. This gives you very fast writes, but introduces a window where the database is behind the cache. If the cache crashes before flushing, you lose data.
Used for high-throughput writes where temporary inconsistency is acceptable (analytics, view counters, activity feeds).
Cache Invalidation Strategies
"There are only two hard things in Computer Science: cache invalidation and naming things." Phil Karlton wasn't joking. Here are the approaches that actually work:
TTL-Based Expiration
Set a time-to-live on every cache entry. After it expires, the next request fetches fresh data. Simple and predictable. The downside: data can be stale for up to the entire TTL duration.
// Redis TTL — expires after 5 minutes
await redis.setex('product:123', 300, JSON.stringify(product))
// HTTP Cache-Control — browser caches for 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600')Event-Driven Invalidation
When data changes, immediately delete or update the related cache entries. This gives you near-zero staleness but requires you to know exactly which cache keys are affected by each write operation.
// After updating a user
await db.users.update(userId, newData)
await redis.del(`user:${userId}`) // Delete specific key
await redis.del('users:list:page:1') // Invalidate related listVersioned Keys
Include a version number in the cache key. When data changes, increment the version. Old entries naturally expire via TTL while new requests use the updated key.
// Version-based cache key
const version = await redis.get('products:version') // e.g., "7"
const cacheKey = `products:v${version}:page:${page}`
// On product update — increment version
await redis.incr('products:version')
// All old cache keys (v7) become orphaned and expire via TTLPurge / Manual Invalidation
Explicitly purge specific URLs or cache entries through an API or admin interface. CDNs like Cloudflare and Fastly support instant purge by URL, tag, or entire zone. Useful for content publishing workflows where editors need changes live immediately.
Code Examples
Redis Caching in Node.js
A complete cache-aside implementation with error handling and TTL:
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
async function cached(key, ttlSeconds, fetchFn) {
try {
const hit = await redis.get(key)
if (hit) {
console.log(`Cache HIT: ${key}`)
return JSON.parse(hit)
}
} catch (err) {
// Cache failure shouldn't break the app
console.warn('Cache read error:', err.message)
}
// Cache miss — execute the expensive operation
const result = await fetchFn()
try {
await redis.setex(key, ttlSeconds, JSON.stringify(result))
console.log(`Cache SET: ${key} (TTL: ${ttlSeconds}s)`)
} catch (err) {
console.warn('Cache write error:', err.message)
}
return result
}
// Usage
const user = await cached(
`user:${userId}`,
300, // 5 minutes
() => db.users.findById(userId)
)HTTP Cache-Control Headers
Setting proper cache headers in Express/Next.js:
// Static assets — cache for 1 year (use content hash in filename)
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true
}))
// API response — private, short cache, revalidate in background
app.get('/api/products', (req, res) => {
res.setHeader('Cache-Control', 'private, max-age=60, stale-while-revalidate=300')
res.setHeader('ETag', computeETag(products))
res.json(products)
})
// HTML pages — always revalidate, but use ETag for 304 responses
app.get('/dashboard', (req, res) => {
res.setHeader('Cache-Control', 'no-cache') // must revalidate every time
res.setHeader('ETag', `"${pageVersion}"`)
res.render('dashboard')
})
// Sensitive data — never cache
app.get('/api/user/billing', (req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.json(billingData)
})React Query / TanStack Query — Stale Time
Client-side caching with automatic background refetching:
import { useQuery } from '@tanstack/react-query'
function ProductList() {
const { data, isLoading } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
refetchOnWindowFocus: true, // Refetch when user returns to tab
refetchOnReconnect: true, // Refetch after network recovery
})
// Data is served instantly from cache on subsequent renders
// Background refetch happens silently after staleTime expires
}Common Caching Mistakes
These trip up developers at every level. Avoid them:
1. Over-Caching User-Specific Data
Caching personalized responses with a generic key means users see each other's data. Always include the user ID in cache keys for private data, and set Cache-Control: private on personalized HTTP responses.
2. Serving Stale Data Without Knowing
Long TTLs without invalidation means users see outdated prices, incorrect inventory, or old content. If data changes frequently, use short TTLs or event-driven invalidation — not "cache forever and hope."
3. Cache Stampede (Thundering Herd)
A popular cache entry expires and 500 requests simultaneously hit the database. Solutions: use a lock so only one request refills the cache, add jitter to TTLs, or serve stale content while revalidating in the background.
4. Not Warming the Cache
After a deployment or cache flush, every request is a cold miss. For critical paths, pre-warm the cache on startup by fetching frequently-accessed data before traffic hits the new instance.
5. Caching Error Responses
If your API returns a 500 error and you cache it, every subsequent request gets the cached error for the entire TTL. Always check the response status before caching — only cache successful results.
6. Ignoring Cache in Development
Developing with caching disabled means you never catch caching bugs until production. Test with cache enabled locally. Reproduce "user sees old data" bugs before they reach users.
Caching Best Practices
Cache at the right layer. Static assets → CDN/browser. Computed results → Redis. Query results → database level. Don't cache everything in the same place.
Always set a TTL. Cache entries without expiration live forever. Even if you have event-driven invalidation, a TTL acts as a safety net for edge cases.
Use content hashes for static assets. Name files like app.a3b2c1.js so you can cache them for a year. When you deploy new code, the filename changes and the browser fetches the new version.
Make cache failures non-fatal. If Redis is down, your app should still work — just slower. Wrap cache operations in try/catch and fall through to the source on failure.
Monitor hit rates. A cache with 30% hit rate isn't helping much. Track hits, misses, and evictions. If hit rate is low, you're either caching the wrong things or your TTL is too short.
Add jitter to TTLs. If 1000 keys all expire at the same second, you get a stampede. Add random jitter: TTL = baseTTL + random(0, 60) spreads expiration across time.
Log cache operations in development. Add logging that shows HIT/MISS/SET/DEL for every cache operation during development. This makes it obvious when the cache isn't behaving as expected.
Think about cold starts. New deployments, new instances, and Redis restarts all mean cold caches. Have a plan: pre-warming scripts, gradual traffic shifting, or stale-while-revalidate strategies.
Frequently Asked Questions
What is caching in web development?+
What are the 4 layers of caching?+
What is cache invalidation and why is it hard?+
What is the difference between Cache-Aside and Read-Through?+
What is stale-while-revalidate?+
What is a cache stampede?+
When should I use Redis vs in-memory caching?+
How do I set Cache-Control headers correctly?+
Cache-Control: public, max-age=31536000, immutable with content hashing in filenames. For HTML pages, use no-cache or short max-age. For API responses, use private, max-age=60 or no-store for user-specific data. Always pair with ETag for conditional requests.When NOT to Cache
Not everything benefits from caching. Some data should always come fresh from the source:
Real-time data
Stock prices, live sports scores, chat messages — data that changes every second loses value the instant it's cached. Use WebSockets or server-sent events instead.
Security-sensitive operations
Authentication tokens, permission checks, and access control decisions should never be cached at the application level. A stale permission cache means a revoked user still has access.
Write-heavy data with low read frequency
If data is written more often than it's read, caching adds complexity without benefit. You'll spend more time invalidating than serving from cache.
Large, unique datasets
If every request is for a unique piece of data (search queries, user-specific reports), cache hit rates will be near zero. You'll burn memory storing entries nobody reads twice.
Measuring Cache Effectiveness
A cache you can't measure is a cache you can't trust. Track these metrics:
| Metric | What It Tells You | Target |
|---|---|---|
| Hit Rate | Percentage of requests served from cache | > 80% for most workloads |
| Miss Rate | Percentage of requests that go to origin | < 20% (inverse of hit rate) |
| Eviction Count | Entries removed due to memory pressure | Low — high means cache is undersized |
| Latency (p50/p99) | Response time from cache vs origin | Cache: < 5ms, Origin: 50-500ms |
| Stale Serve Rate | How often stale data is served | Depends on tolerance — 0% for payments |
| Memory Usage | How much RAM your cache consumes | Stay under 80% of allocated memory |
Redis provides these metrics via INFO stats (keyspace_hits, keyspace_misses). For CDNs, check your provider's analytics dashboard. For browser caching, use Chrome DevTools Network tab and look for "(disk cache)" or "(memory cache)" indicators.
Related Articles & Tools
Conclusion
Caching is not optional for production applications. It's the difference between a 2-second response and a 5ms response, between handling 100 requests/second and 10,000. But it's not magic — you need to choose the right layer, pick the right strategy, and have a plan for invalidation.
Start simple: add Cache-Control headers to your static assets and use Redis for your most expensive database queries. Measure hit rates, watch for stale data issues, and layer in more caching as you understand your access patterns. The fastest request is the one that never hits your server.
