Get started with 33% off your first certification using code: 33OFFNEW

The practical guide to HTTP caching headers: Cache-Control, ETag, and 304s

8 min read
Published on 11th August 2025

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 overrides max-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 as app.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 sends If-None-Match: "<etag>". If the tag matches the current version on the server, the server replies 304 Not Modified with no body.
  • With Last-Modified, the browser sends If-Modified-Since: <date>. If nothing changed since then, the server returns 304.

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

  1. Using no-cache when you actually meant no-store. The first still allows caching, it only forces revalidation. The second forbids storage.
  2. Serving dynamic HTML with Cache-Control: max-age=0 and no validator. That guarantees a cache miss and a full download on every request.
  3. Setting Vary on a header that changes per request, such as User-Agent or a custom tracking header. Your CDN’s cache will fragment into thousands of variants.
  4. Omitting s-maxage on shared caches. Without it, your CDN will use the browser max-age, which might be too short for the edge.
  5. 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 either no-cache or no-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

  1. Do assets have hashed filenames and Cache-Control: public, max-age=31536000, immutable
  2. Do HTML pages send either ETag or Last-Modified plus a sane Cache-Control
  3. Does your CDN key include only the Vary headers you actually need
  4. Do private pages send private, no-store or at least private, no-cache
  5. Can you prove with curl -I that a second request returns 304 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.