FE-05 — Network & Caching Strategy
HTTP Headers, the Cache Hierarchy, and Designing for Repeat Visits
Part of The Production Frontend Engineer series. Prerequisites: FE-00 (HTTP, CRP, browser internals), FE-03 (bundles, content hashing), FE-04 (TTFB, LCP root causes).
Opening
By the end of this chapter, a returning user loading your application will feel like a different product to you than it did before. You will understand that for a user who has visited your app in the last week, the network is almost entirely irrelevant — every asset can be served from their own device in milliseconds — and whether that happens depends entirely on four HTTP headers you control. You will understand why a deployment can show stale content to some users for 24 hours even when you shipped correct cache-busting. You will be able to design a caching architecture from scratch for any application, knowing exactly which assets get which headers, why, and what breaks if you get it wrong. Caching is not a configuration detail. It is the difference between a 5-second load and a 200-millisecond load for the majority of your users — and it is entirely under your control.
Part 1: The Physical Reality of Network Latency — Why Caching Is Not Optional
Before any optimisation, start with physics. The speed of light in a vacuum is 299,792 km/s. Through fibre optic cable (which light travels through as pulses) it is approximately 200,000 km/s, accounting for the refractive index of glass. A data centre in Virginia and a user in Mumbai are approximately 13,500 km apart. At the speed of light through fibre, a signal takes 67ms to travel that distance one way — 134ms for a round trip. This is the theoretical minimum latency for any request between those two points.
In practice, signals do not travel in straight lines. They go through routing equipment, repeaters, undersea cables, and multiple network hops. Real-world RTT between Mumbai and Virginia typically runs 180-250ms. Every uncached resource a Mumbai user loads from a Virginia origin server costs at minimum one round trip — 200ms — before the first byte of that resource arrives. For a page that loads 30 resources, even with HTTP/2 multiplexing, several sequential round trips are unavoidable.
This is not a problem you can solve by making your server faster. A server that responds in 1ms is still 200ms away from the user. The only solution to latency is physical proximity — which is what CDNs provide — or eliminating the request entirely — which is what caching does.
When a resource is cached in the browser, the "request" takes between 0.01ms (memory cache) and 5ms (disk cache). No network. No server. No CDN. The resource is on the user's device. For a returning user, aggressive caching is the single highest-impact performance optimisation available, because it removes the speed-of-light constraint entirely.
Part 2: The Browser's Cache Hierarchy — Four Layers in Order
The browser does not have one cache. It has four, checked in order. Understanding the hierarchy tells you where resources come from and what it costs to retrieve them.
Request for a resource
↓
1. Memory Cache (RAM)
├── Hit: ~0.01ms, no disk access, no network
└── Miss: check next layer
↓
2. Service Worker Cache (if a service worker is installed)
├── Hit: ~1-5ms, JavaScript execution, no network
└── Miss: check next layer
↓
3. Disk Cache (HTTP cache, persistent storage)
├── Hit: ~2-10ms, disk read, no network
└── Miss: check next layer
↓
4. Network (DNS + TCP + TLS + server + transfer)
└── ~50ms minimum (local CDN) to 500ms+ (distant origin)
Store result in disk cache for future requests
Memory cache: the fastest cache. Resources loaded during the current page session are held in RAM. A stylesheet loaded in the <head> is in memory cache — if anything else on the same page requests it (unusual but possible), it is served from RAM instantly. Memory cache is cleared when the tab is closed.
Service Worker cache: a programmable cache controlled entirely by JavaScript you write. A Service Worker is a JavaScript file that runs in its own thread, intercepts all network requests from your page, and can respond with cached resources, network responses, or any combination. This is the most powerful layer — you write the cache logic. Its cost is JavaScript execution time (the service worker code runs to determine how to respond). More in Part 5.
Disk cache (HTTP cache): the primary caching layer for most applications. Resources stored here persist across browser sessions. A stylesheet loaded on Monday is still in disk cache on Friday (unless evicted by cache pressure or expired by cache headers). The browser decides whether to use the disk cache based on the HTTP headers the server sent with the resource. These headers are the primary tool you control. The entire next two parts are about them.
Network: the fallback when nothing is cached. The cost here is everything FE-00 and FE-04 described — DNS, TCP, TLS, server processing, transfer.
The practical implication of this hierarchy: your optimisation priority for returning users should be in reverse order. Network optimisations (compression, CDN, HTTP/2) help every user but are the most expensive to improve. Disk cache hits are free after the first load. Memory cache hits are faster still. Service Worker cache gives you programmatic control for offline and complex caching strategies.
Part 3: HTTP Cache Headers — The Four You Must Know Cold
The HTTP cache is governed entirely by response headers your server sends. The browser reads these headers, decides whether and how long to cache the response, and whether to validate with the server before using a cached copy. Four headers cover 95% of real-world caching.
Cache-Control — The Primary Directive
Cache-Control is a response header (and can be a request header) that tells the browser and any intermediate caches (CDN, proxy) exactly how to handle this response.
Cache-Control: public, max-age=31536000, immutable
Cache-Control: private, no-cache
Cache-Control: no-store
Cache-Control: public, max-age=0, must-revalidate
The directives that matter:
max-age=N: cache this response for N seconds. After N seconds, the response is "stale" — the browser must either revalidate it with the server or fetch a fresh copy. max-age=31536000 is one year (31,536,000 seconds). max-age=0 means "immediately stale."
public: this response can be cached by any cache, including shared CDN caches. Appropriate for assets that are identical for all users (JS bundles, images, fonts).
private: this response is specific to one user and must only be cached in the browser, not in shared CDN caches. Appropriate for API responses containing user data. If your /api/user/profile response was cached in a shared CDN cache and served to a different user, that would be a data breach.
no-store: do not cache this response at all, anywhere. Not in the browser, not in a CDN. The browser must fetch fresh on every request. Use for genuinely sensitive responses (authentication tokens, payment information, real-time data that must never be stale).
no-cache: misleadingly named. It does not mean "do not cache." It means "you can store this in cache, but you must validate with the server before using it." The browser will check with the server on every request, but if the server confirms nothing has changed (responds with 304 Not Modified), no data is transferred. Saves bandwidth, does not save round trips.
immutable: this response will never change while it is fresh. The browser should not even attempt revalidation within the max-age window — skip the conditional request entirely. Use only with content-hashed assets. If you serve main.abc123.js with max-age=31536000, immutable and the file changes, the URL changes (new hash), so the immutability claim is always true.
must-revalidate: when the cache entry becomes stale (after max-age), the browser must revalidate with the server before serving it. Cannot serve stale content even if the server is unreachable. The default behaviour is to sometimes serve stale content when the server is unavailable — must-revalidate prevents this.
stale-while-revalidate=N: serve the stale cached response immediately (zero latency), while simultaneously making a background request to refresh the cache. If the cached response is stale by less than N seconds, use it. The user gets instant response; the cache is updated silently. This is the HTTP-level implementation of the pattern React Query uses at the application level.
Cache-Control: public, max-age=60, stale-while-revalidate=600
This means: "Consider this fresh for 60 seconds. If it's between 60 and 660 seconds old, serve it immediately from cache but update the cache in the background. After 660 seconds, require a fresh fetch."
ETag — The Resource Fingerprint
An ETag (entity tag) is a server-generated identifier for a specific version of a resource. It is typically a hash of the resource's content.
ETag: "abc123def456"
When the browser has a cached resource with an ETag, and the cache is stale (past max-age), instead of re-downloading the entire resource, it sends a conditional request:
GET /main.js HTTP/1.1
If-None-Match: "abc123def456"
"I have version abc123def456. Has it changed?"
The server compares the current resource's ETag to the one in the header. If they match (resource unchanged), it responds:
HTTP/1.1 304 Not Modified
No body. No data transfer. Just a round trip to confirm the cached version is still valid. The browser continues using the cached copy. This saves bandwidth entirely — the user does not re-download the resource. It does not save the round-trip latency — there is still one network round trip.
For content-hashed assets (where the URL changes when content changes), ETags are redundant — if the URL is the same, the content is guaranteed to be the same. ETags matter for resources without content hashing — your index.html, your API responses, your dynamically generated resources.
Last-Modified — The Time-Based Alternative to ETag
Last-Modified: Wed, 15 Jan 2025 10:30:00 GMT
An alternative to ETag that uses modification time instead of a content hash. The browser sends:
GET /document.html HTTP/1.1
If-Modified-Since: Wed, 15 Jan 2025 10:30:00 GMT
The server checks if the resource has been modified since that timestamp. If not, 304 Not Modified. If yes, sends the full response.
ETags are preferred over Last-Modified because timestamps have second-level granularity (two versions of a file modified in the same second appear identical) and server clock synchronisation is not always perfect. When both are present, the browser uses ETag.
Vary — Making Cache Keys Correct
The HTTP cache key is, by default, the URL. Two requests for the same URL get the same cached response. But some responses legitimately differ based on request headers — a server that sends different content to Chrome vs Firefox (based on the Accept header), or different content to compressed vs uncompressed clients (based on Accept-Encoding).
Vary: Accept-Encoding
Vary: Accept-Encoding tells the cache: "the response depends on the Accept-Encoding request header." The browser stores separate cached copies for gzip and br compressed versions. A CDN stores separate copies per encoding variant.
Vary: Cookie or Vary: Authorization tells caches to treat each unique cookie/auth value as a separate cache key — effectively disabling shared caching (CDN) for that resource, since every user has different cookies. This is why API responses that depend on the user's session should use Cache-Control: private rather than Vary: Cookie — private is clearer and less likely to cause caching bugs.
Part 4: Designing a Caching Architecture from Scratch
With the four headers understood, here is the complete decision framework for assigning cache strategy to every type of resource in a web application.
The Asset Taxonomy
Every resource in a web application falls into one of five categories. Each has a correct caching strategy derivable from its properties.
Category 1: Content-hashed static assets
JavaScript bundles, CSS files, images, fonts — anything your build tool produces with a content hash in the filename (main.abc123.js, Inter-abc456.woff2).
Properties: the URL changes when the content changes. The same URL will always return the same bytes forever.
Correct strategy:
Cache-Control: public, max-age=31536000, immutable
One year, immutable. The browser caches these permanently. When you deploy new code, the URLs change (new hashes), the browser fetches fresh. Old URLs remain valid and correctly cached. This is the correct strategy for everything your bundler produces with content hashing.
Category 2: The HTML document
index.html (or any server-rendered HTML page) is the entry point that references all the hashed assets. It cannot be content-hashed itself — users navigate to /dashboard, not /dashboard?hash=abc123.
Properties: changes on every deployment (new script/link tags with new hashes). Must be checked on every visit.
Correct strategy:
Cache-Control: no-cache
Not no-store. no-cache means "cache it, but validate before using." The browser stores a copy, but on every visit sends a conditional request with ETag or If-Modified-Since. If nothing changed (user visits between deployments), the server responds 304 — no data transfer, but one round trip. When you deploy, the HTML changes, the server responds 200 with the new HTML. The user gets the new file referencing the new hashed assets.
Why not no-store? no-store means the browser downloads the entire HTML on every visit — even between deployments when nothing changed. no-cache is smarter: it validates (one lightweight round trip), downloads only when necessary.
Why not max-age=300 (cache for 5 minutes)? If you deploy while a user is in the 5-minute window, they will receive the old HTML (pointing to old JS/CSS URLs) for up to 5 minutes. The old URLs are still cached and valid, so the app "works" — but they are running the old version of your application. For most apps this is acceptable. For applications where deployments fix security issues or critical bugs, stale HTML is a real problem.
Category 3: API responses — shared data
Public data that is identical for all users — a product catalogue, a blog post list, public documentation.
Properties: changes infrequently, same for all users, can be stale by minutes without harm.
Correct strategy:
Cache-Control: public, max-age=300, stale-while-revalidate=3600
Fresh for 5 minutes. Between 5 minutes and 1 hour stale, serve from cache but revalidate in background. This means users always get fast responses (from cache), the cache stays reasonably fresh (background revalidation), and CDN caches can store these responses (reducing origin load).
Category 4: API responses — user-specific data
User profile, settings, personalised recommendations, account data.
Properties: specific to one user, must not be stored in shared CDN caches.
Correct strategy:
Cache-Control: private, no-cache
private prevents CDN caching (the response must not be stored in shared caches). no-cache allows browser caching but requires revalidation. The user's browser can cache their own profile response and avoid re-downloading it on every page load (one round trip for validation vs full download), while CDNs never see the content.
Category 5: Real-time or sensitive responses
Authentication tokens, payment flows, real-time data feeds, any response that must never be stale.
Correct strategy:
Cache-Control: no-store
No caching anywhere. Every request hits the network.
The Complete Caching Table
Resource Type | Cache-Control | ETag?
───────────────────────┼────────────────────────────────────────────┼──────
JS/CSS (hashed) | public, max-age=31536000, immutable | No
Images (hashed) | public, max-age=31536000, immutable | No
Fonts (hashed) | public, max-age=31536000, immutable | No
index.html | no-cache | Yes
Server-rendered pages | no-cache | Yes
Public API data | public, max-age=300, s-w-r=3600 | Yes
User API data | private, no-cache | Yes
Auth/payments | no-store | No
Part 5: CDNs — Moving the Server to the User
Even with aggressive browser caching, the first visit always hits the network. A CDN (Content Delivery Network) solves the latency problem for that first request by storing copies of your content at edge nodes distributed globally.
When a user in Mumbai requests your application, instead of their request traveling 13,500km to Virginia, it goes to the nearest CDN edge node — perhaps in Mumbai or Singapore, 10-50ms away. The edge node either has a cached copy (cache hit) or fetches from your origin server and caches the response for future requests (cache miss, then fill).
The CDN cache is governed by the same Cache-Control headers your server sends. Cache-Control: public, max-age=31536000, immutable on a JavaScript bundle means the CDN caches it for one year — every subsequent user who requests that bundle from any CDN edge node gets it from the edge, not from your origin. Your origin server only sees one request per unique asset per edge node — potentially serving millions of users from one cached copy.
Cache-Control: private prevents CDN caching — the response goes all the way to your origin for every user. This is correct for user-specific data (otherwise user A's data would be served to user B) but means your origin bears the full request load.
CDN Cache Invalidation — The Hard Part
CDN edges cache responses according to Cache-Control. If your origin server for index.html sends no-cache, CDN edges do not cache it — they pass every request through to origin. If your origin sends max-age=3600, the CDN caches for an hour.
The problem: CDN caches are not under your direct control the way your origin is. You cannot simply redeploy your server and have all CDN edges serve the new version immediately if they have cached the old one.
The solutions:
Cache-busting via URL change: content hashing for assets. When content changes, URL changes, CDN sees a new resource to cache. Old cached URLs remain valid (and still correctly return old versions). This is why content hashing is the correct solution — not just for browser caching, but for CDN caching.
CDN cache purge API: most CDN providers (Cloudflare, Fastly, AWS CloudFront) offer an API to purge specific URLs or tags from their cache immediately. If you deploy a new index.html, call the CDN's purge API to evict the old cached version from all edges. Most deployment platforms (Vercel, Netlify) do this automatically on deploy.
Surrogate keys / Cache tags: advanced CDNs support tagging cached responses with arbitrary keys. You tag all API responses for a specific user's data with their user ID. When that user's data changes, you purge all responses tagged with their ID — a single purge call invalidates everything related to that user across all edge nodes. Cloudflare calls these "Cache Tags," Fastly calls them "Surrogate Keys."
Part 6: Service Workers — Programmable Caching
The browser's HTTP cache is governed by server-sent headers. Service Workers give you programmatic control over caching from the client side — a JavaScript file that intercepts every network request and can respond with whatever it chooses: a cached response, a network response, or a combination.
A Service Worker is registered by your page and runs in its own thread (separate from the main thread and the page itself). It persists after the page is closed and can intercept requests even when no page is open (enabling background sync and push notifications, though those are beyond this chapter's scope).
// sw.js — the Service Worker file
const CACHE_NAME = 'app-v1'
const PRECACHE_ASSETS = [
'/',
'/main.abc123.js',
'/styles.def456.css',
]
// Install: runs once when the SW is first registered
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(PRECACHE_ASSETS) // fetch and cache these immediately
})
)
})
// Activate: runs after install, when the SW takes control
self.addEventListener('activate', event => {
event.waitUntil(
// Delete old cache versions from previous deployments
caches.keys().then(keys => {
return Promise.all(
keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
})
)
})
// Fetch: intercepts every network request from the page
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request)
})
)
})
This is the simplest useful Service Worker: precache specific assets on install, delete old caches on activate, and serve from cache when available (falling back to network). This gives users of your app instant loads on repeat visits — the assets are on their device before they even make a request.
The Three Core Caching Strategies
Service Workers implement caching strategies that combine cache and network in different ways. Each strategy is correct for different types of resources.
Cache-First (offline-first)
Serve from cache if available. Only go to network if not cached. Network responses are stored in cache for future use.
async function cacheFirst(request) {
const cached = await caches.match(request)
if (cached) return cached
const response = await fetch(request)
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone()) // store for next time
return response
}
Use for: versioned static assets (JS, CSS, fonts, images). These never change at the same URL. Cache-first means zero network cost on every visit after the first.
Network-First (freshness-preferred)
Try the network first. Fall back to cache if network fails.
async function networkFirst(request) {
try {
const response = await fetch(request)
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone())
return response
} catch (error) {
const cached = await caches.match(request)
return cached || new Response('Offline', { status: 503 })
}
}
Use for: HTML documents, API responses that must be fresh when online but should work offline. Always fresh when network is available, graceful degradation when offline.
Stale-While-Revalidate
Serve from cache immediately (zero latency). Simultaneously fetch from network and update cache. User always sees fast response; cache is kept up to date.
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(request)
// Start network request regardless of cache hit
const networkPromise = fetch(request).then(response => {
cache.put(request, response.clone())
return response
})
// Return cache immediately if available, otherwise wait for network
return cached || networkPromise
}
Use for: resources that change but where slight staleness is acceptable — blog post lists, user avatars, non-critical API data. Users always get instant responses; data is refreshed in the background.
Service Worker Lifecycle — The Tricky Part
Service Workers have a lifecycle that causes confusion when deploying updates. When you deploy a new Service Worker (say, sw.js has changed), the browser downloads the new version — but it does not activate it immediately. The new Service Worker enters a "waiting" state until all tabs using the old Service Worker are closed.
The reason: if both old and new Service Workers ran simultaneously in different tabs, you could have users on different versions of your app — with different caches, different behavior — simultaneously. The browser prevents this by ensuring only one version of a Service Worker controls pages at a time.
The practical consequence: a user who never closes your app's tab may run the old Service Worker for days after you deploy. Your cached assets may be stale from before the deployment.
The fix: call skipWaiting() in the install event to force the new Service Worker to activate immediately, and clients.claim() in the activate event to take control of all open pages:
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting()) // activate immediately
)
})
self.addEventListener('activate', event => {
event.waitUntil(
Promise.all([
// Delete old caches
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
),
// Take control of all open pages
self.clients.claim()
])
)
})
skipWaiting and clients.claim together ensure new deployments take effect for all users on their next request, not their next browser session.
Part 7: Preconnect, DNS-Prefetch, and Resource Hints
For resources that must come from the network (first visits, resources that cannot be cached), resource hints tell the browser to perform expensive setup work before the resource is actually needed.
The Physical Cost of a New Origin Connection
When your page requests a resource from a new origin (a different domain or subdomain), the browser must:
- Resolve the DNS name to an IP address (~20-120ms, uncached)
- Establish a TCP connection (one round trip, ~50-200ms)
- Perform a TLS handshake (one to two additional round trips, ~50-400ms)
Before the first byte of the resource can transfer, 120-720ms of setup cost must be paid. For a resource on the same origin (same domain, already connected), this cost is zero — the connection is reused.
Every third-party resource (Google Fonts, CDN for a JavaScript library, your analytics domain) is a separate origin. Every separate origin pays this full setup cost on the first request.
preconnect
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
preconnect tells the browser: start the DNS + TCP + TLS handshake for this origin now, before I make any specific request to it. By the time the browser encounters the actual resource request (a font URL, a script tag), the connection is already established. The setup cost is already paid.
The effect: the resource transfer starts immediately when the request is made, rather than after 120-720ms of setup. On a page that uses Google Fonts, adding preconnect to fonts.googleapis.com and fonts.gstatic.com typically saves 200-400ms of font load time — which directly improves LCP if text is the LCP element, and CLS if the font swap was causing layout shifts.
The crossorigin attribute on the second preconnect is required for fonts because fonts are fetched with CORS. Without it, the browser establishes an anonymous connection, and the actual font request (which is CORS) requires a new connection — the preconnect is wasted.
dns-prefetch
<link rel="dns-prefetch" href="https://analytics.example.com">
A weaker hint than preconnect. Only performs DNS resolution (step 1), not TCP or TLS. Lower resource cost than preconnect — no actual connections are established. Use for origins you might need but are not certain about, or for origins where you want to reduce DNS lookup cost without committing to a full connection.
The practical difference: preconnect is for origins you know you will use on this page (your font provider, your image CDN). dns-prefetch is for origins you might use or that are less critical.
prefetch and preload — Recap from FE-03
preload: download a resource immediately at high priority, for use on the current page. For LCP images, critical fonts, essential scripts.
prefetch: download a resource at low priority during idle time, for use on a future navigation. For the next page's JavaScript chunk.
The complete resource hint vocabulary:
preconnect → establish connection to an origin (DNS + TCP + TLS)
dns-prefetch → resolve DNS for an origin only
preload → download a specific resource now (high priority, this page)
prefetch → download a specific resource later (low priority, future page)
Part 8: The Stale Content Problem — Why Deployments Sometimes Show Old Versions
You deploy a critical bug fix. The fix is live on the server. A user reports they are still seeing the bug. You check: the server is definitely returning the correct response. What happened?
This is the stale content problem, and it has several distinct causes depending on which layer of the cache hierarchy is involved.
Cause 1: CDN edge has not updated
If your HTML is cached at a CDN edge with a non-zero max-age, the edge serves the old HTML until the cache expires or you purge it. Users who reach that edge node see the old version. Fix: set Cache-Control: no-cache on HTML documents, and use your CDN's purge API on deployment.
Cause 2: Service Worker is still on the old version
If your app has a Service Worker and you deployed but the Service Worker update is "waiting" (because a tab is still open), the Service Worker continues serving old cached assets. Fix: skipWaiting and clients.claim in the Service Worker.
Cause 3: The browser's disk cache has a valid (non-stale) cached response
If a user loaded your page 10 minutes ago and your HTML has Cache-Control: max-age=3600, their browser will not re-request it for another 50 minutes. They are running your app in memory from the previous load. Fix: use no-cache on HTML, not a positive max-age.
Cause 4: Your index.html is cached with a long max-age somewhere in the chain
This is the most dangerous mistake: setting Cache-Control: max-age=86400 (one day) on index.html. Users who visit between deployments will run old HTML (pointing to old JS/CSS hashes) for up to 24 hours. The old JS/CSS URLs are still valid and correctly cached — the app "works" on the old version — but they are not getting your bug fix. Fix: index.html must always be no-cache.
The invariant that prevents all four causes:
- Content-hashed assets:
max-age=31536000, immutable(URL changes on every deploy) - HTML:
no-cache(validated on every visit) - Service Worker:
skipWaiting+clients.claim(takes effect immediately on deploy) - CDN: purge on every deployment (most platforms do this automatically)
With these four things in place, every user sees the latest version of your application within one browser request after deployment.
Part 9: HTTP/2 and HTTP/3 — How the Transport Protocol Affects Caching Strategy
In FE-00 you learned that HTTP/2 introduced multiplexing — multiple requests over one TCP connection — and HTTP/3 moved from TCP to QUIC (UDP-based, eliminating TCP head-of-line blocking). These changes affect caching strategy in ways that are not immediately obvious.
HTTP/1.1 affected caching strategy: because browsers opened at most 6 connections per origin and each connection handled one request at a time, loading 30 separate files meant severe queuing. Engineers worked around this with bundling (fewer, larger files), inlining (put small files directly in HTML), and domain sharding (spread files across multiple domains to get more parallel connections). All of these were workarounds for a protocol constraint.
HTTP/2 makes these workarounds counterproductive: with multiplexing, 30 small files load as efficiently as 3 large files — they share one connection, interleaved. Inlining is now harmful — an inlined script cannot be cached (it is part of the HTML document, which has no-cache). Domain sharding is now harmful — it creates multiple TCP connections where one multiplexed connection would be faster and avoid TLS renegotiation costs.
The HTTP/2 caching implication: many small, precisely cached files are now correct. Instead of one large vendor.js bundle (all your dependencies together, forcing a cache miss whenever any dependency updates), you can split into react.js, recharts.js, tanstack-query.js — each with its own cache lifetime. When you update recharts, only the recharts.js chunk has a new hash. Users who visit after the update re-download only that chunk. Everything else remains cached. On HTTP/1.1, this granular splitting would have been too many requests; on HTTP/2, it is the correct strategy.
HTTP/3's QUIC and caching: QUIC runs over UDP, establishing connections with 0-RTT resumption (after the first connection, subsequent connections to the same origin resume with no round-trip overhead). This makes preconnect hints less critical for repeat visitors who have previously connected to the origin — QUIC can resume the connection faster than preconnect can establish a new one. For first visitors, preconnect still helps.
Part 10: Practical Audit — Diagnosing Caching Problems in DevTools
Step 1: Identify what is and is not cached
DevTools → Network tab → reload the page (do not force-refresh with Ctrl+Shift+R — that bypasses cache, which is not what you want to measure).
In the "Size" column, resources served from cache show "(memory cache)" or "(disk cache)" instead of a file size. Resources that made a network request show their transfer size. Resources with a 304 response revalidated successfully.
Sort by "Size" descending. If you see large resources (your main JS bundle) without "(memory cache)" or "(disk cache)" on a repeat load, your caching headers are wrong.
Step 2: Check the headers for each resource
Click a resource in the Network tab → Headers tab. Look at the Response Headers section:
Cache-Controlshould be present and appropriate for the resource typeETagshould be present on resources that useno-cache- Content-hashed files should have
immutablein their Cache-Control
Missing Cache-Control entirely means the browser uses its own heuristic (typically a fraction of the Last-Modified age) — unpredictable and usually wrong.
Step 3: Measure the impact
Performance tab → record two navigations: one with empty cache (simulating first visit), one with warm cache (simulating return visit). The difference in load time between the two is the caching benefit.
Lighthouse → Performance audit → "Serve static assets with an efficient cache policy" lists every resource with suboptimal caching. It shows the cache lifetime for each resource and the potential savings if caching were improved.
Step 4: Verify CDN behaviour
Your CDN's dashboard or its cf-cache-status (Cloudflare), x-cache (CloudFront), or equivalent response header tells you whether the response was served from CDN edge or from origin. HIT means served from cache, MISS means fetched from origin, BYPASS means the CDN decided not to cache (check your Cache-Control header — private or no-store causes bypass).
Vocabulary Map
| Term | What it physically is |
|---|---|
| Browser cache hierarchy | The ordered sequence of four locations the browser checks before making a network request: memory (RAM), Service Worker, disk (HTTP cache), network |
| Memory cache | Resources held in RAM from the current page session — retrieval takes ~0.01ms, cleared when the tab closes |
| Disk cache (HTTP cache) | Resources persisted to disk with browser-controlled expiry based on server-sent HTTP headers — survives browser restarts, cleared when headers expire or browser evicts under storage pressure |
Cache-Control | An HTTP response header whose directives (max-age, public, private, no-cache, no-store, immutable) tell the browser and intermediate caches exactly how to store and validate a response |
max-age | A Cache-Control directive specifying the number of seconds a cached response is "fresh" — after this time the browser must revalidate or refetch |
no-cache | A Cache-Control directive meaning "store this response in cache but revalidate with the server before using it" — does NOT mean "do not cache" |
no-store | A Cache-Control directive meaning "do not store this response anywhere" — every request hits the network |
immutable | A Cache-Control directive signalling that this URL's content will never change while within the max-age window — browser skips revalidation entirely |
stale-while-revalidate | A Cache-Control directive allowing the browser to serve a stale cached response immediately while fetching a fresh version in the background |
ETag | A server-generated identifier (usually a hash) for a specific version of a resource — used in conditional requests to check if content has changed without re-downloading it |
304 Not Modified | An HTTP response code meaning "the resource you have cached is still current" — sent with no body, saving bandwidth while confirming cache validity |
| CDN (Content Delivery Network) | A globally distributed network of servers that cache your static assets at edge nodes near users — reduces latency by serving content from physical proximity rather than origin |
| CDN cache invalidation | The process of removing cached entries from CDN edges — done via purge APIs or URL changes (content hashing) to ensure users receive updated content after deployment |
| Service Worker | A JavaScript file running in its own browser thread that intercepts all network requests from a page and can respond with cached content, network responses, or combinations thereof |
| Precaching | The Service Worker strategy of fetching and caching specific assets during the install event — before any page requests them — ensuring instant availability |
| Cache-First | A Service Worker strategy that always serves from cache when available and only goes to network when the cache has no match |
| Stale-While-Revalidate | A caching strategy (at both HTTP header and Service Worker level) that serves a cached response immediately while simultaneously fetching a fresh version to update the cache |
preconnect | An HTML resource hint that tells the browser to perform DNS resolution, TCP connection, and TLS handshake for a specified origin before any request is made to it |
dns-prefetch | An HTML resource hint that resolves DNS for a specified origin without establishing a TCP connection — lower cost than preconnect, useful for uncertain or non-critical origins |
| Content hashing | Including a hash of a file's content in its filename — the URL changes when content changes, enabling permanent CDN and browser caching while guaranteeing freshness after deployment |
Practice Problems
Problem 1 — Failure Analysis
A team deploys a critical security patch to their React SPA. The patch is in auth.utils.ts, which is bundled into main.js. They verify the new main.js is on their server. Twenty minutes after deployment, 15% of users are still running the unpatched version. The team's caching configuration is: index.html served with Cache-Control: max-age=1800, all JS bundles served with Cache-Control: public, max-age=31536000, immutable. Trace through the exact mechanism by which users are still running the old code twenty minutes after deployment. Why is only 15% affected rather than 100%? What single change to the caching configuration would have prevented this?
Problem 2 — Design from Scratch
Design the complete caching architecture for a SaaS application with the following characteristics: a Next.js frontend with server-rendered HTML, content-hashed JS/CSS bundles, a REST API that returns user-specific data (profile, settings) and public data (product catalogue updated once per day), user-uploaded images stored in object storage, and a Cloudflare CDN in front of everything. For each resource category, specify the exact Cache-Control header, whether ETags are needed, whether CDN caching is appropriate, and what happens to users' cached copies when you deploy a new version. Your design must ensure users see updates within 30 seconds of deployment without requiring a forced refresh.
Problem 3 — Synthesis
A developer implements a Service Worker with a Cache-First strategy for all resources including the API responses for user data. After launch, users report that updating their profile information in settings does not reflect in the main dashboard — they see their old name even after the profile API returned the updated value. Using what you know about Service Worker caching strategies, the browser cache hierarchy, and the nature of user-specific API data, explain exactly why this happened and what it reveals about when Cache-First is the wrong strategy. Redesign the Service Worker caching strategy so that static assets remain offline-capable while API responses are always fresh.
Problem 4 — Go Deeper
The stale-while-revalidate directive at the HTTP level and the Stale-While-Revalidate strategy in a Service Worker appear to do the same thing. They are not the same. Reason through the specific differences between how they work at the physical level — which layer of the cache hierarchy each operates at, who executes the revalidation request, what happens to in-flight requests when the fresh response arrives, and whether the user can be served different versions of the same resource in quick succession. Under what conditions would one approach cause problems that the other would not? Design a scenario where using both simultaneously creates a race condition.
Problem 5 — Previous Chapter Connection (FE-06)
In FE-06 you learned that will-change promotes an element to its own GPU compositor layer, pre-allocating VRAM for its pixel bitmap. This chapter established that preconnect pre-establishes TCP/TLS connections before resources are needed. Both are "speculative allocation" patterns — spending a resource (VRAM / network connection) before you are certain it will be needed, to eliminate latency at the moment of need. Compare these two patterns across four dimensions: (1) the resource being pre-allocated, (2) the cost if the speculation turns out to be wrong (the resource is never used), (3) the mechanism for "releasing" the pre-allocation when no longer needed, and (4) the production failure mode if the pattern is applied too broadly. What does this comparison reveal about the general principle of speculative resource allocation in browser performance engineering?
What's Coming Next
You now control the caching layer — you understand what happens to bytes after they leave your server and before they arrive in the browser, and you can architect a system where repeat users load your application from their own device in hundreds of milliseconds regardless of network conditions. FE-07 goes inside the browser's memory model — not the cache, but the heap. JavaScript objects, closures, DOM references, and event listeners all live on the V8 heap, managed by a garbage collector that can cause visible pauses. In single-page applications that run for hours without a page reload, memory that is never freed accumulates until the browser tab slows down and eventually crashes. Knowing how to find those leaks, trace them to their source, and fix them permanently is the skill that keeps production SPAs healthy at scale.