Testing Client-Side Caching Strategies
Measuring the impact of HTTP cache headers, service workers, and local storage caching on repeat visit performance and data freshness.
Context
A content-heavy web application served 200,000 page views/day with a 65% return visitor rate. Repeat visits loaded the same JavaScript, CSS, and API data. I tested three client-side caching strategies to reduce load times for returning users while maintaining data freshness.
Problem
Client-side caching reduces network requests and improves load times, but introduces staleness. Different caching mechanisms have different eviction behaviors, storage limits, and freshness guarantees. Choosing the wrong strategy means either stale content or no caching benefit.
Constraints
- Application: Next.js with client-side data fetching (SWR)
- Static assets: JavaScript, CSS, images (~2.5MB total)
- API responses: JSON, 20 endpoints, average response 8KB
- Acceptable staleness for content: 5 minutes
- Acceptable staleness for user-specific data: 0 (must be fresh)
- Target: repeat visit load time under 1 second on 4G
- Browser support: Chrome, Safari, Firefox (latest 2 versions)
Design
Strategy 1: HTTP Cache Headers Only
Configured caching via response headers:
# Static assets (hashed filenames)
Cache-Control: public, max-age=31536000, immutable
# API responses (content data)
Cache-Control: public, max-age=300, stale-while-revalidate=60
# API responses (user-specific)
Cache-Control: private, no-cache
No JavaScript caching logic. Browser handles everything.
Strategy 2: Service Worker with Cache-First Strategy
A service worker intercepts all fetch requests and serves from cache when available:
// sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = ['/app.js', '/styles.css', '/manifest.json'];
const API_CACHE_TTL = 300_000; // 5 minutes
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (STATIC_ASSETS.some(a => url.pathname.endsWith(a))) {
event.respondWith(cacheFirst(event.request));
} else if (url.pathname.startsWith('/api/content')) {
event.respondWith(staleWhileRevalidate(event.request, API_CACHE_TTL));
} else {
event.respondWith(fetch(event.request));
}
});
async function staleWhileRevalidate(request, ttl) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
if (cached) {
const cachedTime = cached.headers.get('x-cached-at');
if (cachedTime && Date.now() - parseInt(cachedTime) < ttl) {
fetchPromise.catch(() => {}); // Update in background
return cached;
}
}
return fetchPromise;
}Strategy 3: SWR (Stale-While-Revalidate) Library
React-level caching with SWR:
function useContent(slug: string) {
const { data, error, isValidating } = useSWR(
`/api/content/${slug}`,
fetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: true,
dedupingInterval: 300_000, // 5 minutes
fallbackData: getLocalStorageCache(slug),
onSuccess: (data) => setLocalStorageCache(slug, data),
}
);
return { content: data, error, loading: isValidating };
}This caches responses in memory (for the current session) and localStorage (for persistence across sessions).
Trade-offs
Repeat Visit Performance (4G, product listing page)
| Metric | No Caching | HTTP Headers | Service Worker | SWR + localStorage |
|---|---|---|---|---|
| TTFB | 320ms | 0ms (cache hit) | 0ms (SW intercept) | 320ms (API fresh) |
| FCP | 1,800ms | 600ms | 450ms | 800ms |
| LCP | 2,400ms | 900ms | 650ms | 1,100ms |
| Total requests | 25 | 8 (17 cache hits) | 3 (22 SW hits) | 25 (all fire, stale shown first) |
| Data transferred | 2.8MB | 0.4MB | 0.1MB | 2.8MB (background) |
Freshness Comparison
| Strategy | Content Staleness | User Data Staleness | Update Mechanism |
|---|---|---|---|
| HTTP Headers | 0-300s | 0 (no-cache) | Browser revalidation |
| Service Worker | 0-300s | 0 (bypassed) | Background fetch |
| SWR + localStorage | 0-300s | 0 (always fresh) | Revalidation on mount |
Storage Limits and Behavior
| Mechanism | Storage Limit | Eviction Policy | Persistence |
|---|---|---|---|
| HTTP cache | Browser-managed (~250MB) | LRU by browser | Survives restart |
| Service Worker cache | Origin-based (~50MB) | Manual or quota-based | Survives restart |
| localStorage | 5-10MB | None (fails on full) | Survives restart |
| SWR in-memory | JS heap | Garbage collected | Session only |
Offline Capability
| Strategy | Offline Support |
|---|---|
| HTTP Headers | Partial (cached assets work, API calls fail) |
| Service Worker | Full (SW serves cached responses for all routes) |
| SWR + localStorage | Partial (shows stale data, API calls fail visibly) |
Failure Modes
See also: Failure Modes I Actively Design For.
Service worker update race: When deploying a new version, the old service worker continues serving cached assets until the user closes all tabs and reopens. This can serve stale JavaScript that is incompatible with new API responses. Mitigation: version the service worker cache name and skip waiting on activation.
localStorage quota exceeded: On Safari, localStorage is limited to 5MB. If the application caches too many API responses, writes fail silently (setItem throws). Mitigation: implement LRU eviction in the localStorage wrapper, and catch quota errors.
HTTP cache poisoning: If a CDN caches a response with incorrect Vary headers, all users receive the same cached response regardless of their authentication state. This is a security issue for personalized content. Mitigation: use Cache-Control: private for any response that varies by user.
Stale-while-revalidate showing outdated prices: If a product price changes, users see the stale price for up to 300 seconds. For a 5-minute window, this means some users may add items to cart at the old price. Mitigation: validate prices server-side at checkout, and reduce TTL for price-sensitive data.
Service worker cache growing unboundedly: Without explicit eviction, the service worker cache stores every unique API response. Over weeks of use, this can consume hundreds of megabytes. Mitigation: implement a max-age check on cache entries and prune on every fetch event.
Scaling Considerations
- HTTP cache headers scale to any number of users with zero additional infrastructure. The CDN and browser handle everything.
- Service workers add client-side complexity but reduce server load proportionally to the cache hit rate. At 80% hit rate across 200,000 daily page views, that eliminates 160,000 requests/day.
- localStorage caching is limited by quota. For applications with large datasets, IndexedDB (with a 50MB+ quota) is the better persistence layer.
- The combination of HTTP cache headers (for static assets) and SWR (for API data) provides the best balance of simplicity and performance.
Observability
- Measure cache hit rate via the
performance.getEntriesByType('resource')API (checktransferSize === 0for cache hits) - Track service worker cache size via the Cache API and report to analytics
- Monitor localStorage usage and eviction events
- Compare FCP and LCP between first visits and repeat visits to quantify caching impact
- Alert on cache hit rate dropping below expected threshold (indicates a deployment that invalidated caches)
Key Takeaways
- HTTP cache headers are the highest-ROI caching mechanism. Correct
Cache-Controlheaders require no client-side code and work across all browsers. - Service workers provide the best repeat visit performance (450ms FCP vs 800ms for SWR) by intercepting network requests before they leave the browser.
- SWR with localStorage provides a good balance of simplicity and persistence without the complexity of a service worker.
- Never cache user-specific data in shared caches (CDN, public HTTP cache). Use
Cache-Control: privateorno-cachefor personalized responses. - Implement cache eviction proactively. Client-side storage limits are real, and exceeding them fails silently.
Further Reading
- Testing Caching Strategies in Real Conditions: Comparing cache-aside, write-through, and read-through strategies with measured hit rates, latency, and consistency trade-offs under prod...
- When Caching Makes Things Worse: Real scenarios where adding a cache increased complexity, introduced bugs, or degraded performance, and the decision framework I use to e...
- Implementing Server-Side Rendering Without Overhead: Techniques for reducing SSR latency including streaming, selective hydration, component-level caching, and measured performance gains.
Final Thoughts
I deployed HTTP cache headers for static assets (immutable, 1-year max-age) and SWR with in-memory caching for API data (5-minute deduplication interval). This reduced repeat visit FCP from 1,800ms to 800ms with minimal code changes. The service worker approach delivered better numbers (450ms FCP) but the complexity of managing cache invalidation across deployments was not justified for this application. The simpler approach solved 80% of the problem with 20% of the effort.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.