How HTTP Caching Works: Cache-Control, ETag, and CDN Caches Explained

HTTP caching makes websites faster by reusing responses instead of downloading or generating them again. A browser can reuse a CSS file from local storage, a CDN can serve an image without contacting the origin, and a reverse proxy can return a complete page without running PHP or querying a database.

The difficult part is not enabling a cache. It is deciding which response may be stored, who may store it, how long it remains fresh, and what must happen when it becomes stale. A bad policy can serve old files, leak personalized pages between users, or make deployments appear broken. A good policy reduces latency and server work while keeping content correct.

This guide explains browser caches, shared caches, Cache-Control, ETag, Last-Modified, 304 Not Modified, CDN behavior, and practical configurations for common website content.

The HTTP caching layers

A response may pass through several independent caches between the application and the visitor:

  1. Application cache: WordPress page caching, a framework response cache, or generated static HTML.
  2. Reverse-proxy cache: Nginx, Varnish, Apache mod_cache, or a hosting platform cache in front of the application.
  3. CDN cache: geographically distributed edge servers such as Cloudflare.
  4. Browser cache: private storage controlled by the visitor’s browser.

These layers do not necessarily use the same cache key or expiration time. A CDN might keep an asset for a day while instructing the browser to keep it for an hour. A browser cache belongs to one user, while a CDN or reverse proxy is a shared cache that may serve the same stored response to thousands of users.

That distinction is fundamental: a response that is safe in a private browser cache may be dangerous in a shared cache.

Freshness, staleness, and revalidation

A stored response is fresh while its defined lifetime has not expired. A cache can normally reuse a fresh response without contacting the origin. Once the lifetime expires, the response becomes stale. Stale does not always mean unusable; the cache may ask the origin whether its copy is still current.

That check is called revalidation. If the resource has not changed, the origin can return 304 Not Modified without sending the response body again. If it has changed, the origin returns a new 200 OK response and representation.

Freshness avoids a network round trip entirely. Revalidation still requires a request, but it can save bandwidth and avoid retransmitting a large body.

Cache-Control directives explained

Cache-Control is the primary header for explicit caching behavior. Multiple directives are separated by commas.

Directive Meaning Typical use
max-age=N Response becomes stale after N seconds Browser and shared-cache freshness
s-maxage=N Shared-cache freshness, overriding max-age there Different CDN and browser lifetimes
public Allows storage by shared caches Public static files or cacheable pages
private Allows private caches but forbids shared-cache storage Personalized responses
no-cache Allows storage but requires validation before reuse HTML that must remain current
no-store Forbids caches from storing the response Sensitive responses
must-revalidate Requires successful validation once stale Content that must not be served stale
immutable Signals that a fresh response will not change at its URL Versioned or hashed assets
stale-while-revalidate=N Permits stale reuse while refreshing in the background Latency-sensitive public content
stale-if-error=N Permits stale reuse when the origin fails Resilience for non-sensitive public content

no-cache does not mean do not cache

This is the most common HTTP caching misunderstanding. no-cache allows a response to be stored. It means that the stored response must be validated with the origin before it is reused. When the cached copy is still current, the origin can answer with a small 304 response.

Cache-Control: no-cache

Use no-store when the response must not be stored in a browser, CDN, proxy, or other compliant cache:

Cache-Control: no-store

Examples include responses containing particularly sensitive personal or financial information. Do not combine every restrictive directive out of habit. A header such as private, no-cache, no-store, max-age=0 is mostly noise because no-store already prohibits storage.

public and private control shared caches

private means the response is intended for one user and must not be stored by shared caches. It may still be stored by that user’s browser. A personalized account page might use:

Cache-Control: private, no-cache

The browser may store the response but must revalidate it before reuse. The CDN must not store it.

public explicitly allows shared-cache storage. It is useful for public resources, particularly when normal HTTP rules would otherwise make a response non-cacheable. Do not add public to a page merely because it can be visited without logging in; verify that its content, cookies, location, currency, experiments, and permissions cannot vary unexpectedly between users.

max-age and s-maxage set different lifetimes

max-age defines freshness in seconds. The age is measured from when the response was generated, not simply from when a cache received it. Shared caches commonly expose the current age through an Age header.

s-maxage applies only to shared caches and overrides max-age there. This lets a CDN refresh content more frequently while browsers retain it longer, or vice versa:

Cache-Control: public, max-age=300, s-maxage=3600

In this example, a browser treats the response as fresh for five minutes, while a compliant shared cache may keep it fresh for one hour. CDN cache rules can override origin headers, so always verify the behavior of the deployed configuration rather than assuming the header is the final authority.

ETag and Last-Modified are cache validators

Freshness determines when a cached response can be reused without asking. Validators help a cache check a stale response efficiently.

An ETag is an identifier for a particular representation. When the cache has an ETag, it can send it in If-None-Match:

If-None-Match: "33a64df5"

If that identifier still matches, the origin returns 304 Not Modified. Otherwise it returns the new content with a new ETag.

Last-Modified contains the modification timestamp. A cache can send it back through If-Modified-Since. Timestamps are less precise than representation-specific ETags, but they remain useful to caches, crawlers, and content-management systems. Current HTTP guidance favors sending both when practical; If-None-Match takes precedence when both conditional request headers are present.

HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "33a64df5"
Last-Modified: Mon, 13 Jul 2026 08:00:00 GMT

What a 304 Not Modified response does

A 304 Not Modified response tells the client that its stored representation is still valid. It has no response body. The browser combines the 304 metadata with its cached body and presents the resource as though it had received the full response.

A 304 is useful, but it is not as fast as a fresh cache hit: the browser still contacted the server and waited for a response. For versioned static assets that cannot change at the same URL, a long freshness lifetime is usually better than repeated revalidation.

The best pattern for versioned static assets

Build systems often place a content hash or version in filenames:

<link rel="stylesheet" href="/assets/app.a91f4c2.css">
<script src="/assets/app.738bc11.js" defer></script>

When the file changes, the URL changes. The old URL can therefore receive a long cache lifetime safely:

Cache-Control: public, max-age=31536000, immutable

This strategy is called cache busting. The HTML document should not receive the same long immutable policy because it must be able to reference the new asset filenames after deployment.

A practical policy for HTML

For non-personalized HTML that should always reflect the latest deployment, use validators with:

Cache-Control: no-cache

This allows storage while requiring validation before reuse. If page generation is expensive and a small amount of staleness is acceptable, a public page can instead use a short s-maxage at the CDN while browsers revalidate more frequently:

Cache-Control: public, max-age=0, s-maxage=300, must-revalidate

That pattern is appropriate only for genuinely public HTML. Logged-in pages, carts, checkout flows, dashboards, previews, password-protected content, and responses affected by user-specific cookies must bypass shared caches or use a rigorously designed cache key.

Vary changes the cache key

The Vary response header tells caches which request headers influence the representation. For compressed responses, a common example is:

Vary: Accept-Encoding

A cache can then keep separate compressed and uncompressed variants. Other possibilities include Accept for content negotiation or Accept-Language for language variants.

Use Vary carefully. Each request-header value can create another cache variant, reducing the hit rate. Vary: Cookie is especially broad because cookies often differ for nearly every visitor. For user-dependent pages, bypassing the shared cache is usually safer than varying on the entire Cookie header.

CDN caching versus browser caching

A CDN cache is shared and close to visitors. A browser cache is private and eliminates even the trip to the CDN while a response is fresh. The two layers can be controlled independently with s-maxage and max-age, though CDN-specific rules may augment or override origin behavior.

With Cloudflare Origin Cache Control enabled, origin Cache-Control directives generally guide edge behavior. Cloudflare Cache Rules can change edge and browser TTLs, eligibility, cache keys, and bypass conditions. Other CDNs provide similar controls with different defaults.

Useful diagnostic response headers include:

  • Age: seconds the response has spent in a shared cache.
  • CF-Cache-Status: Cloudflare cache outcome such as HIT, MISS, BYPASS, or DYNAMIC.
  • X-Cache: a common vendor or proxy-specific cache status header.
  • Via: intermediaries that handled the request.

A MISS followed by a HIT is a typical healthy sequence for cacheable content. Repeated misses can indicate an uncacheable response, a cache-key variation, eviction, a rule conflict, cookies, authorization, or an origin directive that prevents storage.

A cautious Nginx example

This example gives versioned static assets a long lifetime while requiring HTML revalidation. Adapt the file types and routing to the application.

location ~* \.(?:css|js|jpg|jpeg|png|gif|svg|webp|avif|woff2)$ {
    try_files $uri =404;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    access_log off;
}

location / {
    try_files $uri $uri/ /index.php?$args;
    add_header Cache-Control "no-cache" always;
}

Only use the one-year immutable policy when changing content also changes the URL. If files such as logo.svg are overwritten in place, reduce the lifetime or add versioned filenames.

Nginx proxy caching is a separate mechanism from response headers. Before enabling it for dynamic pages, define explicit bypass rules for authorization, login cookies, previews, carts, checkout, non-GET requests, and application-specific private routes.

A cautious Apache example

With mod_headers, Apache can set policy by filename. This example assumes static asset filenames are versioned:

<IfModule mod_headers.c>
  <FilesMatch "\.(css|js|jpg|jpeg|png|gif|svg|webp|avif|woff2)$">
    Header always set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  <FilesMatch "\.(html|htm)$">
    Header always set Cache-Control "no-cache"
  </FilesMatch>
</IfModule>

Apache mod_cache can also act as a shared caching layer, but it should be enabled carefully. Its quick handler can serve cached content early in request processing, so do not cache content protected by host-, address-, environment-, or application-level access logic without understanding the request path.

WordPress caching mistakes to avoid

WordPress can involve browser caching, a page-cache plugin, PHP OPcache, object caching, a server-level proxy cache, and a CDN simultaneously. Each solves a different problem.

  • Page cache: stores complete rendered HTML for anonymous requests.
  • Object cache: reuses database results and computed objects; it does not replace a page cache.
  • OPcache: stores compiled PHP bytecode; it does not cache page output.
  • CDN: usually caches static files and may cache public HTML when configured.

Never let shared caches mix anonymous and authenticated output. Exclude WordPress administration, login, previews, password-protected posts, carts, checkout, account pages, and responses with meaningful user-specific state. Purge affected page and edge caches when publishing or editing content.

For a broader view of these layers, see our guide to the fastest server stack configuration for WordPress.

How to debug HTTP caching with curl

First inspect the final response headers:

curl -ILs https://example.com/

Request the same cacheable asset twice and compare Age, CF-Cache-Status, X-Cache, ETag, and Last-Modified. To test an ETag manually:

curl -I https://example.com/assets/app.a91f4c2.css

curl -I \
  -H 'If-None-Match: "33a64df5"' \
  https://example.com/assets/app.a91f4c2.css

If the representation is unchanged and the ETag matches, the second request should return 304 Not Modified. A normal browser reload, hard reload, disabled-cache setting in developer tools, and back/forward navigation can behave differently, so use controlled requests when diagnosing the origin.

Common caching mistakes

  • Treating no-cache as no-store: the former permits storage and requires validation.
  • Caching personalized HTML publicly: this can expose one user’s content to another.
  • Using immutable URLs without versioning: visitors may keep obsolete files until the lifetime expires.
  • Setting long browser TTLs you cannot purge: purging a CDN does not remove a fresh response from every browser.
  • Ignoring the cache key: query strings, hostnames, headers, cookies, and CDN rules may create separate entries.
  • Using Vary: Cookie indiscriminately: it can destroy the shared-cache hit rate.
  • Forgetting error and redirect responses: cached redirects or errors can persist after the underlying problem is fixed.
  • Stacking conflicting rules: application, web server, plugin, hosting cache, and CDN settings may disagree.
  • Purging instead of versioning assets: content-addressed filenames are more reliable than global cache purges.
  • Measuring only the first request: a cold-cache MISS says little about steady-state cache performance.

A practical caching checklist

  1. Classify every response as public, personalized, or sensitive.
  2. Give every important response an explicit Cache-Control policy.
  3. Use hashed filenames and long immutable caching for build assets.
  4. Use validators for content that must remain current at a stable URL.
  5. Keep personalized responses out of shared caches.
  6. Document the cache key and bypass conditions at every shared layer.
  7. Verify origin, reverse-proxy, CDN, and browser headers separately.
  8. Purge page and edge caches when content changes, but prefer URL versioning for assets.
  9. Monitor hit ratios, origin load, stale responses, and user reports after changes.

Frequently asked questions

Is browser caching the same as CDN caching?

No. A browser cache is private to one user. A CDN is a shared cache that can serve many users. Use max-age and s-maxage when they need different freshness lifetimes.

Should HTML use no-cache or no-store?

Use no-cache for ordinary non-sensitive HTML that may be stored but should be validated before reuse. Use no-store when storage itself is unacceptable because the response contains sensitive information.

Do I need both ETag and Last-Modified?

Sending both is useful when the server can generate them correctly. ETag provides a representation identifier, while Last-Modified remains useful to caches, crawlers, and other HTTP clients.

Why does my CDN keep returning MISS?

The response may be ineligible for caching, vary by cache key, contain cookies or authorization, be evicted, have an immediately stale policy, or match a bypass rule. Inspect the CDN status header, origin Cache-Control, request cookies, and active cache rules.

Does clearing a CDN cache clear browser caches?

No. A CDN purge removes edge copies but cannot remove fresh files already stored in visitors’ browsers. Change the asset URL when its contents change.

Final thoughts

Effective HTTP caching is a correctness policy first and a performance optimization second. Decide who may store each response, define its freshness, provide validators where appropriate, and ensure personalized content never enters a shared cache.

The most reliable general pattern is simple: version static asset URLs and cache them for a long time, keep stable HTML URLs easy to revalidate, and explicitly bypass shared caches for user-specific or sensitive responses. Then test every layer using the headers a real visitor receives.

0 0 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x