The Fastest Request Is the One That Never Leaves the Browser
Every time a visitor loads your site, their browser makes dozens of requests — images, scripts, stylesheets, fonts. If your server answers every single one from scratch, you're leaving a huge amount of speed on the table.
HTTP caching lets browsers and intermediaries store responses and reuse them. Done right, it can cut page load times in half for repeat visitors, reduce your server load dramatically, and improve Core Web Vitals scores — especially LCP and FID.
But caching headers are easy to get wrong. This post walks through how they actually work, which directives matter, and how to build a caching strategy you can apply right now.
How HTTP Caching Works
When a browser receives a response, it checks whether that response included instructions for how long to store it. Those instructions come in HTTP response headers.
There are two main mechanisms:
- Cache-Control — tells the browser (and any intermediate caches) how long to store a response and under what conditions.
- Conditional requests (ETags and Last-Modified) — let the browser ask the server "has this changed since I last fetched it?" and get a lightweight 304 Not Modified response instead of downloading the full file again.
These two mechanisms work together. Understanding both is key to a solid caching strategy.
Cache-Control: The Directives That Matter
The Cache-Control header is the most important lever you have. Here's what each directive actually does.
max-age
max-age=N tells the browser to consider the response fresh for N seconds. During that window, the browser serves the file directly from its local cache — no network request at all.
Cache-Control: max-age=31536000That's one year. Use it for assets that have a hash in their filename (e.g., main.a3f92b.js), because when the file changes, the URL changes too, bypassing the cache automatically.
no-cache vs. no-store
These sound similar but behave very differently.
- no-cache — the browser can store the response, but must revalidate it with the server before using it. This is often what you want for HTML pages.
- no-store — the browser must not store the response at all. Reserve this for genuinely sensitive data like private API payloads or banking responses.
public vs. private
- public — the response can be stored by any cache, including shared CDN caches and reverse proxies.
- private — only the end user's browser can cache it. Use this for responses that contain user-specific data.
stale-while-revalidate
This directive is underused and extremely effective. It tells the browser: serve the stale cached version immediately, then check for a fresh one in the background.
Cache-Control: max-age=3600, stale-while-revalidate=86400Visitors always get an instant response. The cache refreshes silently. For most content-driven sites, this is a great default for images and fonts.
ETags and Conditional Requests
When a cached file expires, the browser doesn't have to re-download it blindly. It can send a conditional request asking whether the file has changed.
An ETag is a unique identifier the server assigns to a specific version of a file — typically a hash of the file contents. The browser stores it and sends it back on the next request:
If-None-Match: "a3f92b84c1d"If the file hasn't changed, the server responds with 304 Not Modified and no body. That response might be 200 bytes instead of 200 kilobytes. For a page with 30 assets, this adds up fast.
Last-Modified works the same way but uses a timestamp instead of a hash. ETags are generally more reliable because timestamps can drift across server restarts or clustered environments.
A Practical Caching Strategy by Asset Type
The mistake most developers make is applying one caching policy to everything. Different assets have very different change frequencies.
HTML pages
Cache-Control: no-cacheHTML changes frequently and must always be fresh. Use no-cache so browsers revalidate on every visit — but still benefit from a 304 response when nothing has changed.
Versioned static assets (JS and CSS with a hash in the filename)
Cache-Control: public, max-age=31536000, immutableThe immutable directive tells the browser this file will never change at this URL — skip revalidation entirely, even on a force-reload. This is only safe when the URL itself changes whenever the file does.
Images without content-addressed URLs
Cache-Control: public, max-age=604800, stale-while-revalidate=86400One week of freshness with a background revalidation window. Adjust based on how often your images actually change.
API responses
For public, slowly-changing data:
Cache-Control: public, max-age=60, stale-while-revalidate=300For user-specific responses, always use private:
Cache-Control: private, no-cacheHow to Set These Headers on Your Server
In Apache, use your .htaccess file:
<FilesMatch "\.(js|css|woff2)$"> Header set Cache-Control "public, max-age=31536000, immutable" </FilesMatch>In Nginx, use location blocks:
location ~* \.(js|css|woff2)$ { add_header Cache-Control "public, max-age=31536000, immutable"; expires 1y; }The Nginx expires directive sets the older Expires header alongside Cache-Control. Browsers prioritize Cache-Control when both are present, but including both improves compatibility with older clients.
How to Measure the Impact
Before and after making changes, verify with real tools:
- Chrome DevTools Network tab — look at the Size column. Cached responses show "(disk cache)" or "(memory cache)" with near-zero transfer time.
- WebPageTest — run a repeat view test. The gap between first-visit and repeat-visit load times shows exactly how effective your cache policy is.
- Lighthouse — the "Serve static assets with an efficient cache policy" audit flags anything cached for less than 30 days that isn't HTML.
- curl — inspect headers directly from the terminal: curl -I https://yoursite.com/main.js
A well-tuned cache can reduce repeat-visit page weight by 80% or more. For a 2MB page, that means repeat visitors download roughly 400KB — a significant difference on mobile connections.
On the server side, caching also cuts origin requests. When a reverse proxy sits in front of your application, a cache hit means your PHP, Node, or Python process never runs at all. That directly reduces CPU time, memory pressure, and database queries. If you're not sure whether requests are actually hitting your cache or punching through to origin, it helps to have visibility into that pipeline — seeing what's cached, what's blocked, and what reaches your application confirms your layers are working as intended rather than just assumed.
Common Mistakes to Avoid
- Caching HTML aggressively. If visitors have your homepage cached for 24 hours and you push a critical fix, they won't see it until the cache expires.
- Using no-store everywhere "to be safe." This forces a full download on every visit. You're doing extra work for zero benefit.
- Not versioning static assets. Without a hash or version string in the filename, you can't safely use long max-age values. Returning visitors keep seeing stale JS and CSS.
- Forgetting the Vary header. If your server returns different responses based on Accept-Encoding or Accept-Language, add a Vary header so caches store separate versions per variant — otherwise you risk serving the wrong one.
Quick Wins You Can Apply Today
- Run curl -I https://yoursite.com/style.css and check what Cache-Control value you're actually sending.
- Add immutable to any asset whose filename includes a content hash.
- Switch HTML pages from no-store to no-cache so browsers can still use 304 responses.
- Add stale-while-revalidate to image and font rules to eliminate the latency spike that happens the moment a cached file expires.
Caching isn't glamorous, but the payoff is real and immediate. Get the headers right, and repeat visitors will experience a site that feels nearly instant — even if they never know why.