- What a browser and CDN actually cache
- The core headers, in plain language
- Five caching patterns you will reuse weekly
- Common mistakes that quietly ruin caching
- Implementation recipes you can drop into real projects
- How to test your headers in a way that catches the real problems
- Picking numbers that are sensible
- A simple checklist you can keep near your editor
- Frequently asked questions that come up on every project
If your pages feel sluggish, your CDN bill keeps creeping up, or your origin servers look tired at 3 a.m., chances are your caching strategy needs work. The good news is that a handful of headers control most of this story. Get those right and you cut time to first byte, reduce bandwidth, and smooth out traffic spikes without touching a line of application logic.
This guide walks through the headers that matter, the patterns most teams actually use, and copy-paste configs for common stacks. By the end, you will know exactly which headers to send for HTML, APIs, and static assets, how to test them, and how to avoid the traps that quietly disable caching.
What a browser and CDN actually cache
A browser caches responses based on your HTTP headers. A CDN sits between the user and your origin and applies the same rules but at the edge. If a response is declared public and fresh for a period of time, the browser and the CDN can reuse it without asking your origin again. If it is stale or marked as private, they will revalidate or skip caching.
That means your headers are the contract. Be explicit and you win. Be vague and every intermediary will shrug and forward more traffic to your origin.
The core headers, in plain language
Cache-Control
This is the workhorse. You compose it from directives that describe where and how long something can be cached.
Common directives you will actually use:
-
publicallows any cache to store the response, including shared caches like CDNs. -
privatelimits caching to the end user’s browser. Good for personalized pages. -
max-age=SECONDSdefines freshness. During this window caches reuse the response without revalidation. -
s-maxage=SECONDSoverridesmax-agefor shared caches like CDNs. Handy when you want the edge to cache longer than the browser. -
no-cachetells caches to revalidate before reuse. It does not mean do not cache at all. -
no-storeforbids storage entirely. Use this for sensitive or rapidly changing data such as account pages or checkout. -
must-revalidateinstructs caches to check freshness rules again once a response becomes stale. -
immutablesignals that a resource will never change during its lifetime. Great for fingerprinted assets such asapp.49c1f.css. -
stale-while-revalidate=SECONDSallows a cache to serve a slightly stale response while it quietly fetches a fresh one in the background. -
stale-if-error=SECONDSlets caches serve stale content if your origin is unhealthy.
ETag and Last-Modified
These validators help the cache ask a smart question. Instead of refetching the whole resource, the cache can send a conditional request:
- With
ETag, the browser sendsIf-None-Match: "<etag>". If the tag matches the current version on the server, the server replies304 Not Modifiedwith no body. - With
Last-Modified, the browser sendsIf-Modified-Since: <date>. If nothing changed since then, the server returns304.
Use at least one validator for things that are not long-lived immutable assets. Validators cut bandwidth and speed up perceived performance even when your max-age is short.
Expires
A legacy header that sets an absolute expiry date. If you already send Cache-Control, you usually do not need Expires. If both are present, Cache-Control wins.
Vary
This header tells caches which request headers change the response. If your HTML varies by Accept-Encoding, Accept-Language, or an Authorization header, say so. The wrong Vary can explode your cache key and slash hit ratio, so add only what you genuinely need.
Typical values:
-
Vary: Accept-Encodingfor gzip or br compression. Many servers add this automatically. -
Vary: Accept-Languageif you serve different languages at the same URL. -
Vary: Originfor CORS-aware assets that differ by requesting origin. - Avoid
Vary: *which effectively disables caching.
Five caching patterns you will reuse weekly
1) Fingerprinted static assets
CSS, JS, fonts, and images that include a content hash in the filename.
Headers
Cache-Control: public, max-age=31536000, immutable
Why it works: the filename changes on deploy, so you can safely tell caches to keep the old one for a year. Browsers never revalidate, and the CDN serves from edge for months.
2) HTML pages that update often, but not every second
Marketing pages, blog posts, or product detail pages that change hourly or daily.
Headers
Cache-Control: public, max-age=300, stale-while-revalidate=30
ETag: "W/123abc"
Set a small max-age to give users fresh content reasonably fast. Pair with a validator so most refreshes return 304 or serve stale while the edge refreshes in the background.
3) Personalized HTML behind a login
Dashboards and account pages are unique per user.
Headers
Cache-Control: private, no-store
If you must allow back-button history but not disk storage, relax to private, no-cache instead of no-store. Test carefully.
4) JSON APIs
Pick a clear stance. Public catalog endpoints can be cached at the edge. Private data should be private.
Public API
Cache-Control: public, s-maxage=120, max-age=30, stale-while-revalidate=60
ETag: "6b8f-14c"
Private API
Cache-Control: private, no-cache
ETag: "W/9d1e"
5) File downloads
Large files benefit from caching at the edge. Preserve content integrity with validators.
Cache-Control: public, max-age=86400
ETag: "a1b2c3"
Common mistakes that quietly ruin caching
- Using
no-cachewhen you actually meantno-store. The first still allows caching, it only forces revalidation. The second forbids storage. - Serving dynamic HTML with
Cache-Control: max-age=0and no validator. That guarantees a cache miss and a full download on every request. - Setting
Varyon a header that changes per request, such asUser-Agentor a custom tracking header. Your CDN’s cache will fragment into thousands of variants. - Omitting
s-maxageon shared caches. Without it, your CDN will use the browsermax-age, which might be too short for the edge. - Forgetting compression in your mental model. If you pre-compress files, make sure the cache key includes
Accept-Encodingor the CDN stores separate variants for gzip and br.
Implementation recipes you can drop into real projects
Nginx
# Fingerprinted assets
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?)$ {
if ($uri ~* "\.[0-9a-f]{8,}\.") {
add_header Cache-Control "public, max-age=31536000, immutable";
}
try_files $uri =404;
}
# HTML
location / {
add_header Cache-Control "public, max-age=300, stale-while-revalidate=30";
add_header Vary "Accept-Encoding";
etag on;
try_files $uri /index.html;
}
Apache (.htaccess)
# Enable ETag and compression defaults
FileETag MTime Size
<IfModule mod_headers.c>
<FilesMatch "\.(js|css|png|jpg|jpeg|gif|svg|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.(html)$">
Header set Cache-Control "public, max-age=300, stale-while-revalidate=30"
Header append Vary "Accept-Encoding"
</FilesMatch>
</IfModule>
Node with Express
import express from "express";
const app = express();
// Fingerprinted assets from /public/build
app.use(
"/assets",
express.static("public/build", {
maxAge: "365d",
immutable: true,
setHeaders: (res) => {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
},
})
);
// HTML and API examples
app.get("/", (req, res) => {
res.set("Cache-Control", "public, max-age=300, stale-while-revalidate=30");
res.set("Vary", "Accept-Encoding");
res.send(renderHome());
});
app.get("/api/products", (req, res) => {
res.set("Cache-Control", "public, s-maxage=120, max-age=30, stale-while-revalidate=60");
res.json(listProducts());
});
Laravel
Create a small middleware for HTML pages.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HtmlCachingHeaders
{
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
if (str_contains($request->header('Accept'), 'text/html')) {
$response->headers->set('Cache-Control', 'public, max-age=300, stale-while-revalidate=30');
$response->headers->set('Vary', 'Accept-Encoding');
}
return $response;
}
}
Then register it on your web routes. For long-lived assets, let your asset pipeline generate hashed filenames and set headers at the web server or CDN. For JSON APIs, set headers at the controller level with ->header('Cache-Control', '...') or a dedicated API middleware.
WordPress
You generally do asset caching at the web server or CDN, but you can set safe headers in PHP for pages that should not be stored.
add_action('template_redirect', function () {
if (is_user_logged_in()) {
header('Cache-Control: private, no-store');
}
});
For public pages, keep headers in the edge config and focus your theme on proper versioning. When you enqueue assets with wp_enqueue_style and wp_enqueue_script, pass a file hash as the version parameter so the CDN treats each deploy as a new asset.
$ver = filemtime(get_stylesheet_directory() . '/assets/app.css');
wp_enqueue_style('theme-css', get_stylesheet_directory_uri() . '/assets/app.css', [], $ver);
How to test your headers in a way that catches the real problems
Use your browser devtools Network tab. Load a page twice. On the second load you should see from disk cache, from memory cache, or 304. If everything shows 200 with a nonzero size each time, you are not caching.
Use curl to see the raw contract:
curl -I https://example.com/
curl -I https://example.com/assets/app.49c1f.css
Look for Cache-Control, ETag or Last-Modified, and Vary. Then simulate a conditional request:
# Grab ETag, then:
curl -H 'If-None-Match: "6b8f-14c"' -I https://example.com/api/products
A correct setup will return 304 Not Modified with an empty body.
Check the edge. Most CDNs expose a response header like CF-Cache-Status, X-Cache, or Age. You want to see HIT on repeat requests and a growing Age until your max-age expires.
Picking numbers that are sensible
- Fingerprinted assets get a year. That keeps the browser fast and the edge quiet. You do not need to purge on deploy because filenames change.
- HTML for content sites often sits well at 5 to 15 minutes with
stale-while-revalidate. If you publish breaking content, drop to 60 to 120 seconds and add a soft purge in your deployment pipeline. - Public APIs tend to use 30 to 120 seconds at the edge with shorter browser lifetimes. That strikes a balance between freshness and cost.
- Authenticated pages should not be stored in shared caches. Use
privateand eitherno-cacheorno-storedepending on how strict you need to be.
These are starting points. Measure hit ratio at the CDN, origin request rate, and p95 latency, then tune.
A simple checklist you can keep near your editor
- Do assets have hashed filenames and
Cache-Control: public, max-age=31536000, immutable - Do HTML pages send either
ETagorLast-Modifiedplus a saneCache-Control - Does your CDN key include only the
Varyheaders you actually need - Do private pages send
private, no-storeor at leastprivate, no-cache - Can you prove with
curl -Ithat a second request returns304or a cache hit
If you can tick those off, you already solved most real-world caching problems.
Frequently asked questions that come up on every project
Do I need both ETag and Last-Modified
No. Either validator works. Use ETag when generating a hash is convenient or when you need strong change detection. Use Last-Modified when you already know the modification time from your database or file system. Many teams ship both for compatibility.
What is the difference between no-cache and no-store
no-cache allows storage but forces revalidation. no-store prevents storage entirely. If something is sensitive or changes on every view, choose no-store.
Should I add Vary: User-Agent for mobile vs desktop
Prefer a single responsive page. If you truly serve different HTML for mobile and desktop at the same URL, you can vary on a custom header or User-Agent, but understand that your cache hit rate will drop and your CDN bill will rise.
Do I still need Expires
If you send Cache-Control, you can skip Expires. Some teams keep both for very old intermediaries. When both are present, Cache-Control takes precedence.
How do I purge
Do not purge fingerprinted assets. For HTML and APIs, either set short TTLs with stale-while-revalidate or trigger soft purges on deploy. Reserve hard purges for mistakes or urgent fixes.
Caching is not magic. It is a contract between your origin, the edge, and the browser. Write that contract clearly with a small set of headers and your site becomes faster, cheaper, and more resilient, often in a single deploy. If you want a starting pull request today, grab the Nginx or Express snippets above and ship them to your staging environment. Then open devtools, hit refresh twice, and watch your waterfall shrink.