- 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:
-
public
allows any cache to store the response, including shared caches like CDNs. -
private
limits caching to the end user’s browser. Good for personalized pages. -
max-age=SECONDS
defines freshness. During this window caches reuse the response without revalidation. -
s-maxage=SECONDS
overridesmax-age
for shared caches like CDNs. Handy when you want the edge to cache longer than the browser. -
no-cache
tells caches to revalidate before reuse. It does not mean do not cache at all. -
no-store
forbids storage entirely. Use this for sensitive or rapidly changing data such as account pages or checkout. -
must-revalidate
instructs caches to check freshness rules again once a response becomes stale. -
immutable
signals that a resource will never change during its lifetime. Great for fingerprinted assets such asapp.49c1f.css
. -
stale-while-revalidate=SECONDS
allows a cache to serve a slightly stale response while it quietly fetches a fresh one in the background. -
stale-if-error=SECONDS
lets 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 Modified
with 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-Encoding
for gzip or br compression. Many servers add this automatically. -
Vary: Accept-Language
if you serve different languages at the same URL. -
Vary: Origin
for 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-cache
when 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=0
and no validator. That guarantees a cache miss and a full download on every request. - Setting
Vary
on a header that changes per request, such asUser-Agent
or a custom tracking header. Your CDN’s cache will fragment into thousands of variants. - Omitting
s-maxage
on 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-Encoding
or 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
private
and eitherno-cache
orno-store
depending 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
ETag
orLast-Modified
plus a saneCache-Control
- Does your CDN key include only the
Vary
headers you actually need - Do private pages send
private, no-store
or at leastprivate, no-cache
- Can you prove with
curl -I
that a second request returns304
or 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.