Caching Strategies for Web Applications: Browser Cache, CDN, and Application Cache

Caching is the single most impactful performance optimization available to web developers. A well-executed caching strategy reduces server load, eliminates redundant network requests, and delivers near-instant page loads for returning visitors. But caching is also notoriously easy to get wrong—stale data, cache poisoning, and invalidation headaches plague teams that treat caching as an afterthought. A layered approach spanning browser cache, CDN, and application-level cache builds resilience without sacrificing freshness.
Browser Cache with Cache-Control Headers
The browser cache is the first line of defense. Setting appropriate Cache-Control headers on your responses tells the browser what to cache and for how long:
# Immutable static assets (fingerprinted filenames)
Cache-Control: public, max-age=31536000, immutable
# HTML pages (short TTL with revalidation)
Cache-Control: public, max-age=0, must-revalidate
# API responses (no browser caching)
Cache-Control: no-store
Fingerprinted assets (bundles with content hashes in filenames) are safe to cache indefinitely because a new deployment changes the filename. For HTML pages, use must-revalidate combined with ETag headers so the browser can make conditional requests:
// Next.js route handler with ETag
export async function GET(req: NextRequest) {
const data = await fetchData()
const etag = generateHash(JSON.stringify(data))
if (req.headers.get('if-none-match') === etag) {
return new Response(null, { status: 304 })
}
return Response.json(data, {
headers: { 'ETag': etag, 'Cache-Control': 'public, max-age=0, must-revalidate' },
})
}
Conditional requests return a 304 Not Modified response with an empty body, saving bandwidth while keeping the cached version fresh.
CDN Caching for Global Distribution
Content Delivery Networks cache responses at edge locations close to users. Configure CDN caching through the s-maxage directive in Cache-Control or through the CDN provider's settings:
# CDN caches for 1 hour, browser for 1 minute
Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400
The stale-while-revalidate directive is particularly powerful. It allows the CDN to serve stale content immediately while fetching a fresh version in the background. This eliminates cache misses entirely for cacheable content:
// API route with stale-while-revalidate pattern
const data = await fetchFromDatabase()
return Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
},
})
With this configuration, users never wait for the API—the CDN either serves fresh content or stale content that's being refreshed asynchronously.
Service Worker Caching with the Cache API
Service workers act as a programmable proxy between the browser and the network. They enable offline support and advanced caching strategies beyond what HTTP headers provide:
// Service worker with cache-first strategy for assets
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/static/')) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetchAndCache(event.request)
})
)
}
// Network-first for API calls
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
cacheResponse(event.request, response.clone())
return response
})
.catch(() => caches.match(event.request))
)
}
})
Cache-first serves assets instantly from the service worker without touching the network. Network-first tries the live API and falls back to cached data when offline. Use a versioned cache name (e.g., static-v2) and delete old caches during the activate event to prevent stale storage buildup.
Application-Level Caching with Redis
For server-side caching that persists across instances and survives restarts, Redis is the industry standard. Use it to cache expensive computations, database query results, and rendered fragments:
import { Redis } from '@upstash/redis'
const redis = new Redis({ url: process.env.REDIS_URL!, token: process.env.REDIS_TOKEN! })
export async function getCachedProducts(categoryId: string) {
const cacheKey = `products:category:${categoryId}`
// Try cache first
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached as string)
// Compute and cache
const products = await db.product.findMany({ where: { categoryId } })
await redis.setex(cacheKey, 300, JSON.stringify(products))
return products
}
Cache Invalidation Strategies
Invalidation is the hardest part of caching. The most reliable pattern is cache tagging—assign tags to cached entries and purge by tag when data changes:
// Tag-based invalidation
await redis.setex(cacheKey, 300, JSON.stringify(products))
await redis.sadd(`cache:tags:product:${product.id}`, cacheKey)
// When a product updates
async function invalidateProduct(productId: string) {
const keys = await redis.smembers(`cache:tags:product:${productId}`)
if (keys.length > 0) await redis.del(...keys)
await redis.del(`cache:tags:product:${productId}`)
}
For CDN-level invalidation, most providers support purge-by-tag or purge-by-pattern. Combined with short TTLs and stale-while-revalidate, you minimize the window where stale data is served while maintaining cache hit rates above 90%.
A layered caching strategy—browser, CDN, service worker, and application-level—creates a resilient system where each layer compensates for the others' weaknesses. Start with aggressive browser caching for assets, add CDN caching with stale-while-revalidate for dynamic content, and use Redis for server-side optimization.
<a href="/services/web-development">Our team builds high-performance web applications</a> with caching strategies that scale. Contact us to audit your current performance and identify caching wins.
Related Insights

API Rate Limiting Strategies: Token Bucket, Leaky Bucket, and Sliding Window
A guide to implementing API rate limiting including token bucket, leaky bucket, sliding window, and distributed rate limiting with Redis for production APIs.

Code Splitting and Lazy Loading in React: Performance Optimization Guide
A comprehensive guide to code splitting and lazy loading in React applications including React.lazy, Suspense, route-based splitting, and component-level chunking.

Core Web Vitals Optimization: Achieving Great Lighthouse Scores in 2026
A practical guide to optimizing Core Web Vitals for great Lighthouse scores including LCP, FID, and CLS improvements for real-world web applications.