Cache-Control: max-age — Complete Guide
You set Cache-Control: max-age=86400, deploy a new stylesheet, and one browser still shows the old design. A CDN reports a cache HIT. Another test shows the new file immediately. Then DevTools displays an HTTP request even though caching is supposedly enabled. None of those observations contradict max-age; they describe different cache layers and different stages of the HTTP caching lifecycle.
Cache-Control: max-age defines how long an HTTP response may normally be treated as fresh, in seconds. It does not guarantee that the response will be physically deleted when that time expires, it does not guarantee that every cache uses the same lifetime, and changing the header does not automatically revoke copies that were cached earlier.
A useful max-age diagnosis therefore cannot stop at one response header. You need to identify the URL being tested, the response type, what the origin server sends, whether a reverse proxy or CDN changes it, what the browser received, whether the stored response is fresh or stale, and how that resource will be invalidated when it changes.
The goal is not simply to choose 3600, 86400, or 31536000. By the end of this guide, you should be able to inspect the actual HTTP path and tell which cache owns the stale copy, what TTL applies there, and whether the problem is really the TTL at all.
What Does Cache-Control: max-age Actually Control?
Cache-Control: max-age defines the freshness lifetime of an HTTP response in seconds. If a response contains max-age=3600, a cache can normally reuse that response without contacting the origin while it remains fresh for up to one hour.
The word fresh matters. A cached object does not necessarily disappear when its freshness lifetime ends. It becomes stale. A stale response may remain physically stored and later be revalidated with the server, replaced by a newer copy, or discarded according to the cache implementation and available storage.
| Directive | Freshness lifetime | Typical interpretation |
|---|---|---|
max-age=0 |
0 seconds | The response is immediately stale. That is not the same instruction as no-store, and it is not identical to no-cache. |
max-age=60 |
1 minute | A short freshness window for content where small delays in updates are acceptable. |
max-age=3600 |
1 hour | A moderate TTL for content that does not need immediate invalidation. |
max-age=86400 |
24 hours | A long browser freshness period for stable resources. |
max-age=31536000 |
365 days | Best reserved for resources whose URL changes whenever their contents change. |
The second trap is the word "cache." A browser cache, CDN edge cache, reverse proxy, Nginx FastCGI cache, and WordPress page cache are separate systems. HTTP max-age does not mean that every one of those systems will physically store the same object for exactly the same number of seconds.
Start with the response that a client receives:
curl -I https://example.com/assets/app.css
curl -I sends a HEAD request, which is convenient for a quick look at headers. For static assets that is often enough. When the URL is dynamic, passes through an application, or behaves differently from the browser, verify it with a real GET as well:
curl -sS -D - -o /dev/null https://example.com/
The second command performs GET, prints the response headers, and discards the body. That distinction matters because HEAD and GET are expected to have similar metadata, but they are not guaranteed to produce identical headers in every application and intermediary path.
For a compact first pass:
curl -sS -D - -o /dev/null https://example.com/assets/app.css \
| grep -iE 'HTTP/|cache-control|age|expires|date|etag|last-modified|vary'
If the output contains Cache-Control: public, max-age=86400, you have established the policy sent with that particular response. You have not yet proved that a CDN uses the same TTL, that another URL has the same policy, or that the browser does not already hold an older copy.
How Do You Know Whether a Cached Response Is Still Fresh?
For a quick shared-cache check, compare the response age with its freshness lifetime. A response carrying max-age=3600 is not "fresh for another hour" every time you inspect it; its age continues to increase after it was generated or validated.
Suppose a request through a CDN returns:
HTTP/2 200
Date: Thu, 10 Sep 2026 18:00:00 GMT
Cache-Control: public, max-age=3600
Age: 2700
The response has a one-hour freshness lifetime and has accumulated roughly 2700 seconds of age. As an operational approximation:
3600 - 2700 = 900 seconds
That leaves about 15 minutes before this shared-cache response becomes stale. It has already lived through most of its TTL.
Do not turn that subtraction into the full HTTP age algorithm. Age is a useful field exposed by shared caches, while the formal current-age calculation also accounts for when responses and requests were transmitted and received. For troubleshooting, Age is a strong clue, not the whole caching model.
There is another detail that catches administrators during CDN checks: no Age header does not prove that no cache was involved. Its presence is useful evidence that the response came from stored cache state, but an intermediary can behave in ways that do not leave you with an Age field in the response you are inspecting. Combine Age with the provider's cache-status header and repeated requests.
Run the same GET twice:
curl -sS -D - -o /dev/null https://example.com/image.webp
sleep 5
curl -sS -D - -o /dev/null https://example.com/image.webp
If a CDN exposes a cache-status header, you might see MISS on the first request and HIT on the second. If Age appears and increases, that strengthens the diagnosis. Header names vary between CDN and reverse-proxy products, so look at the actual response rather than searching only for one vendor-specific field.
If origin says max-age=300 but the public URL comes back with an old HIT and a much larger effective edge lifetime, stop changing the application. The discrepancy is now further out in the chain.
Why Does Old Content Remain After You Change max-age?
Reducing max-age does not retroactively shorten the freshness lifetime of copies that were already cached with the previous header. If a browser received max-age=86400 and you later change the server to max-age=300, the browser may continue using the earlier fresh response until its existing policy allows another request.
This is where a configuration can look correct on the server while one user still sees yesterday's CSS or image. The new rule exists, but that browser has not necessarily requested a response containing it.
Check the current server path with GET:
curl -sS -D - -o /dev/null https://example.com/style.css \
| grep -iE 'HTTP/|cache-control|age|etag|last-modified'
If the response now contains:
Cache-Control: public, max-age=300
while one browser still renders the previous file, repeatedly editing Nginx is the wrong next move. Check the local browser cache, service worker, and the exact asset URL being loaded.
A versioned URL is also a useful isolation test:
https://example.com/style.css
https://example.com/style.css?v=2
If the second URL retrieves new content while the first remains old, the existing URL is being reused somewhere in the cache path. Query-string versioning works with many modern cache stacks, while fingerprinted filenames such as style.a1b2c3.css make the version boundary much clearer.
- Confirm the current headers and content returned by the public URL.
- Check the origin separately if your infrastructure provides a safe origin-testing path.
- Check whether the CDN still has an older edge object.
- Check the browser cache and whether DevTools is changing normal cache behavior.
- Check for a service worker if the site uses one.
- If the origin itself is old, inspect WordPress, application, FastCGI, proxy, or page-cache layers.
A CDN purge can invalidate edge copies, but it does not rewrite a response already stored fresh in a visitor's browser. Clearing a WordPress page cache has the same limitation: it cannot reach into a client and remove a long-lived static asset.
For an urgent CSS or JavaScript deployment, changing the asset URL is more reliable than hoping an old browser policy disappears early. The old object can finish its lifetime while new HTML points to a different resource.
When Should You Use max-age, s-maxage, Expires, no-cache, and no-store?
Cache-Control directives answer different questions. max-age and s-maxage deal with freshness, private and public affect where responses may be stored, no-cache controls reuse without validation, and no-store tells caches not to store the message.
| Directive | Browser cache | Shared cache / CDN | Typical purpose | Easy mistake to make |
|---|---|---|---|---|
max-age=N |
Sets freshness lifetime | Sets freshness unless a shared-cache-specific rule takes precedence | General HTTP freshness | Assuming the object is deleted after N seconds |
s-maxage=N |
Does not set browser freshness | Sets shared-cache freshness and takes precedence over max-age there |
Different browser and edge TTLs | Expecting it to change browser caching |
public |
Allows cache storage subject to other rules | Explicitly marks the response cacheable | Public responses and explicitly shareable content | Adding it blindly to personalized HTML |
private |
Private cache may store it | Shared cache must not store the response in the normal unqualified case | User-specific responses | Reading private as "nothing may cache this" |
no-cache |
May store, but reuse normally requires successful validation | May store, but reuse normally requires successful validation | Keep a stored copy but check before reuse | Assuming it means "never store" |
no-store |
Must not intentionally store the response | Must not intentionally store the response | Responses that should not be retained by caches | Expecting it to erase previously stored copies everywhere |
must-revalidate |
Restricts reuse after the response becomes stale | Restricts stale reuse | Require successful validation before stale reuse | Treating it as another TTL value |
immutable |
Signals that a fresh representation will not change at this URL | Behavior depends on cache support | Fingerprint/versioned static assets | Using it when the bytes can change behind the same URL |
max-age vs s-maxage
s-maxage allows a response to have one freshness lifetime in private caches and another in shared caches. For example:
Cache-Control: public, max-age=300, s-maxage=3600
A browser can treat the response as fresh for five minutes, while a shared cache can use the one-hour s-maxage value. This is useful for public HTML where the browser should check relatively often but the CDN is allowed to reuse an edge copy for longer.
A CDN rule can still override the origin policy. If the effective edge TTL disagrees with the header, compare the CDN configuration with repeated public requests instead of assuming the origin value is the final word.
max-age vs Expires
Expires supplies an absolute expiration time, while max-age supplies a relative freshness lifetime. When a valid max-age response directive is present, it takes precedence over Expires for freshness calculation.
Cache-Control: public, max-age=3600
Expires: Thu, 10 Sep 2026 20:00:00 GMT
Seeing both fields is normal with Apache mod_expires, Nginx expires, CMS plugins, and hosting panels. The troubleshooting problem starts when one layer changes Cache-Control while another still emits an unrelated Expires policy. Read both from the final response.
What happens if there is no max-age or Expires?
The absence of max-age does not automatically mean "not cached." If a response has no explicit freshness information, an HTTP cache can sometimes assign a heuristic freshness lifetime when the response is otherwise eligible for heuristic caching.
You may encounter a response such as:
HTTP/2 200
Last-Modified: Wed, 09 Sep 2026 12:00:00 GMT
ETag: "a821-7f93"
with no Cache-Control: max-age and no Expires. A cache might still store and reuse that response according to its heuristic rules. Implementations differ, so do not diagnose "cache disabled" from the missing max-age field alone.
If predictable caching matters, explicit policy is easier to operate than relying on heuristics. Either define the freshness you want or explicitly require validation/storage behavior.
no-cache vs no-store
no-cache does not mean "do not store." A cache can retain the response, but normally has to validate it before using it for another request. no-store tells caches not to intentionally store the response.
Cache-Control: no-cache
and:
Cache-Control: no-store
therefore solve different problems. This also explains why max-age=0 is not a perfect synonym for no-cache. max-age=0 makes the response stale immediately; no-cache explicitly requires successful validation before reuse. Stale-response rules and extensions can matter, so use the directive that describes the behavior you actually want.
Response max-age vs request max-age
The same name, max-age, can appear in both responses and requests, but the direction changes its meaning. A response such as:
Cache-Control: max-age=3600
tells caches when that response becomes stale. A client request can instead contain:
Cache-Control: max-age=0
which expresses a preference for a stored response whose age is no greater than zero seconds. Request cache directives are advisory to caches rather than a new freshness policy attached to the response.
This is relevant when testing reload behavior. Browsers can send request cache directives during reloads, and the result may differ from an ordinary navigation. If DevTools shows a request despite a fresh-looking server policy, inspect both Request Headers and Response Headers. Do not assume the Cache-Control field you see belongs to the same side of the exchange.
must-revalidate, immutable, and stale-while-revalidate
must-revalidate controls stale reuse after freshness expires. immutable addresses a different situation: a fresh response is declared not to change behind its current URL.
A fingerprinted asset is a natural fit:
Cache-Control: public, max-age=31536000, immutable
stale-while-revalidate can permit temporary reuse of a stale response while a supporting cache revalidates it. That can reduce latency, but it is a separate policy decision rather than another spelling of max-age. Verify that the cache layer you depend on implements the behavior you expect.
What max-age Should You Use for HTML, CSS, JavaScript, Images, Fonts, and APIs?
Choose max-age from the resource's update model, not its extension. The deciding questions are how harmful a stale copy would be and whether the URL changes when the content changes.
The ranges below are starting points for policy design, not universal defaults. A news homepage, documentation page, WordPress theme asset, software bundle, and private API all have different invalidation requirements even if two of them return the same MIME type.
| Resource | Practical starting range | When a longer TTL is safe | Main failure mode |
|---|---|---|---|
| HTML | 0 to a few minutes, or validation-based caching |
When edge caching and invalidation are explicitly designed | Users receive obsolete page content or old asset references |
| Unversioned CSS / JavaScript | Hours to a few days | When overwriting the same URL is rare and delayed updates are acceptable | Old frontend code remains fresh after deployment |
| Hashed CSS / JavaScript | Months up to one year | When every byte change produces a new URL | The build reuses an old URL for changed content |
| Images | Days to months | When images are stable or replacement creates a new URL | An edited image is published behind an already cached URL |
| Fonts | Weeks to one year | When font URLs are versioned or files are effectively immutable | A font is replaced at the same path while clients keep the old copy |
| API / JSON | Endpoint-specific; sometimes zero | Only after checking personalization, authorization, variation, and invalidation | Outdated or user-specific data is reused incorrectly |
| Personalized HTML | Application-specific private policy | Only when private caching has been deliberately designed | User-specific content enters a shared cache |
The most useful comparison is not CSS versus JavaScript. It is an unversioned URL versus a versioned URL:
/assets/app.css
/assets/app.a83f21.css
If you overwrite app.css, every cache holding a still-fresh copy has no reason to know that the server now has different bytes. If one deployment produces app.a83f21.css and the next produces app.4fa72d.css, both versions can coexist. New HTML references the new file, while an older page can continue loading the asset it was built for.
A one-year TTL is therefore not a magic performance setting. It is the final step of a versioning strategy.
Sample several resource types with GET:
curl -sS -D - -o /dev/null https://example.com/
curl -sS -D - -o /dev/null https://example.com/assets/app.css
curl -sS -D - -o /dev/null https://example.com/assets/app.js
curl -sS -D - -o /dev/null https://example.com/uploads/photo.webp
curl -sS -D - -o /dev/null https://example.com/assets/font.woff2
If every URL returns the same cache policy, verify that the uniformity is intentional. A global rule that treats HTML, immutable assets, and personalized responses identically is often the first sign that caching was configured by file-extension examples rather than by application behavior.
How Do You Set Cache-Control: max-age in Nginx?
In Nginx, attach the cache policy to locations that genuinely represent the resource class you intend to cache, then validate the configuration and inspect a real GET response. A regex that matches .css proves only that the file ends in .css; it does not prove that the file is fingerprinted or immutable.
Long caching for a directory that contains only versioned assets
If your build places immutable, content-versioned resources in a dedicated path, that path is safer to target than a broad extension regex. For example:
location ^~ /assets/versioned/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
Use this pattern only when files under /assets/versioned/ really receive new URLs when their contents change. A directory name does not make a resource immutable by itself; your deployment process has to enforce that contract.
A rule such as:
location ~* \.(css|js|png|jpg|webp|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
looks convenient but matches app.css, app.a83f21.css, logo.png, and any other matching file. If those unversioned URLs can be overwritten, copying that rule creates a one-year stale-content problem by design.
Moderate caching for ordinary static files
For assets that can change behind a stable URL, a shorter policy may be more appropriate. Nginx expires can generate an Expires field and a corresponding Cache-Control: max-age value:
location ~* \.(png|jpg|jpeg|webp|woff2)$ {
expires 7d;
}
The correct value still depends on how those files are deployed. Seven days is an example, not a universal recommendation.
Do not add a blanket public policy to dynamic HTML
HTML often needs a short freshness lifetime, but a generic WordPress or WooCommerce location / is the wrong place to paste an unconditional public rule unless the application and cache architecture were designed for it.
For a known public static HTML endpoint, a narrow rule is easier to reason about:
location = /public-status.html {
add_header Cache-Control "public, max-age=300" always;
}
For WordPress pages, account areas, membership sites, APIs, WooCommerce carts, and checkout flows, determine which responses are public and which vary by user before adding shared-cache permission. The web-server location tree does not know your business logic automatically.
Why add_header can disappear in a nested location
Nginx header inheritance has a trap that regularly produces "the config looks right, but curl shows the wrong header." Under the normal inheritance model, add_header directives from a parent level are inherited only when the current level does not define its own add_header directives.
Consider:
server {
add_header Cache-Control "max-age=300" always;
location /assets/ {
add_header X-Asset-Location "yes" always;
}
}
An administrator may expect the /assets/ response to receive both fields. Under the standard inheritance behavior, defining add_header inside that location changes what is inherited from the parent. This is easy to miss in a large virtual host with included files.
Inspect the configuration Nginx actually loaded:
nginx -T | grep -iE 'server_name|location|add_header|expires|cache-control'
The output can be large. Search around the relevant server_name and location rather than reading every include manually.
Avoiding duplicate Cache-Control headers
Before adding another directive, inspect the current response:
curl -sS -D - -o /dev/null https://example.com/assets/app.css \
| grep -iE 'HTTP/|cache-control|expires'
If both expires and add_header Cache-Control ... affect the same response, you can end up with more than one cache policy. Fix the responsible rule instead of stacking another header on top.
Validate any configuration change before reload:
nginx -t
Then reload Nginx using the service-management method appropriate to the system and repeat the same GET request. If nginx -t succeeds but the response still carries the old header, the syntax is valid; the request is probably hitting another rule, application layer, proxy, or CDN.
How Do You Set max-age in Apache and .htaccess?
On Apache, cache headers commonly come from mod_expires, mod_headers, VirtualHost configuration, directory-level configuration, or .htaccess. On Linux shared hosting, the visible .htaccess file may be only one part of the final response path; the same applies when Apache settings are exposed through a panel such as ISPmanager Lite.
Using mod_expires for ordinary static resources
A moderate policy for selected content types can look like:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 day"
ExpiresByType application/javascript "access plus 1 day"
ExpiresByType image/webp "access plus 7 days"
ExpiresByType font/woff2 "access plus 30 days"
</IfModule>
These numbers are examples. The useful part of the configuration is that different resources can receive different lifetimes. If your CSS filenames never change after deployment, a seven-day or one-year example copied from another site can still be wrong for yours.
Long-lived assets should be targeted by deployment structure
If a dedicated directory contains only fingerprinted immutable assets, an .htaccess file inside that directory can apply a long policy without pretending that every CSS or JavaScript file on the site is immutable:
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=31536000, immutable"
</IfModule>
That example assumes the .htaccess file lives only in the directory containing content-versioned resources. If the same rule is placed at the document root, it can affect far more content than intended.
A broad root-level rule such as:
<FilesMatch "\.(css|js|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
does not detect fingerprinting. It matches both app.js and app.81d933.js. Do not use the regex itself as proof that the URL is safe for a one-year immutable policy.
When .htaccess says one TTL but curl shows another
A common support pattern is simple: .htaccess appears to say one day, while the public response says one year. At that point, editing the same line again will not tell you where the larger TTL comes from.
Compare a static and dynamic GET:
curl -sS -D - -o /dev/null https://example.com/
curl -sS -D - -o /dev/null https://example.com/wp-content/themes/theme/style.css
Then look for the header at each possible layer: another .htaccess, VirtualHost config, mod_headers, mod_expires, application output, reverse proxy, and CDN. If the origin returns one day but the public hostname returns one year, Apache is no longer the first place to change.
With server-level access, validate Apache configuration before a reload:
apachectl -t
The exact service command differs between distributions and hosting stacks, so verify the reload mechanism used on that server rather than copying an unrelated service name.
If mod_expires and Header set Cache-Control both touch the same resource, check the final GET response for duplicate or conflicting fields. What Apache configuration appears to say and what the client receives are not always the same thing.
Why Does WordPress Ignore or Replace Your Cache-Control Header?
WordPress is often not the component that controls the final Cache-Control header. A WordPress response can pass through PHP, a page-cache plugin, Nginx or Apache, FastCGI or another server cache, a reverse proxy, hosting-level caching, and a CDN before it reaches the browser.
Different URLs can therefore follow different paths. A CSS file under /wp-content/ may be served directly by Nginx or Apache, while an article page is generated by PHP or returned from page cache. A logged-in request can bypass a cache that serves anonymous visitors.
Page-cache and optimization plugins
A page-cache plugin can store generated HTML and may also add or influence HTTP headers. An optimization plugin can generate new CSS or JavaScript URLs, append versions, combine files, or move assets to another path. Clearing one of those caches refreshes that layer; it does not automatically clear CDN edge storage or a browser's still-fresh asset.
Before changing plugin settings, identify which response type is wrong:
curl -sS -D - -o /dev/null https://example.com/
curl -sS -D - -o /dev/null https://example.com/wp-content/themes/example/assets/app.css
If static CSS is correct and HTML is not, the global static-file rule is unlikely to be the first problem.
Anonymous and logged-in requests can be intentionally different
An anonymous WordPress page might be served as a CDN or server-cache HIT while the same URL for a logged-in administrator is a BYPASS. That can be correct behavior.
Check the anonymous request with curl, then inspect the same URL in DevTools while logged in. Compare:
Cache-Control;- CDN or server cache status;
Age;Vary;Set-Cookie;- request cookies.
Do not paste live authentication cookies into public tickets or shared command logs just to reproduce the test. A logged-in browser session is usually enough to compare behavior safely.
Set-Cookie does not automatically mean "uncacheable"
A response containing Set-Cookie should make you inspect the policy carefully, but the field alone is not a universal HTTP rule that disables caching. If a response carries user-specific data, the application must still emit an appropriate cache policy such as private or no-store when those semantics are required.
This matters on WooCommerce carts, checkout pages, account areas, membership sites, and dashboards. A public shared-cache rule copied from static assets can become a correctness or privacy bug, not merely a performance tuning mistake.
Server-level caching can bypass WordPress completely
Managed hosting and VPS stacks may put FastCGI cache, Varnish, Nginx proxy cache, LiteSpeed caching, or another layer in front of WordPress. If that layer returns a HIT, PHP and WordPress might not execute at all for the request you are testing.
- Test a static file under
/wp-content/. - Test anonymous HTML with a real GET.
- Compare logged-in behavior in browser DevTools if the site has authenticated content.
- Test the origin without the CDN only through a safe origin-testing path.
- Compare cache status,
Age,Vary, andCache-Control. - Only then change the plugin, web server, proxy, or CDN layer that actually introduces the wrong policy.
If the origin response is already correct but the public hostname is not, stop editing WordPress. The wrong TTL is being introduced further down the delivery chain.
How Does max-age Interact with a CDN?
A CDN edge cache and a browser cache can use different freshness lifetimes for the same URL. The CDN may honor origin Cache-Control, use s-maxage, apply an explicit edge rule, bypass caching for selected requests, or replace the origin TTL with another policy.
Browser TTL vs edge TTL
Consider:
Cache-Control: public, max-age=300, s-maxage=3600
A browser can use a five-minute freshness lifetime while a shared cache uses one hour. A new visitor can therefore receive HTML from an edge HIT even though that visitor has no browser copy at all.
A CDN HIT tells you about the edge. It does not tell you whether the browser has five seconds, five minutes, or no local freshness remaining.
Origin Cache-Control vs CDN overrides
An origin might send:
Cache-Control: public, max-age=300
while a CDN rule keeps the object at the edge for much longer. Other rules may bypass caching based on path, cookies, request method, authorization, or application-specific conditions.
Compare public and origin paths when the infrastructure provides a safe way to do that:
curl -sS -D - -o /dev/null https://public.example.com/page
curl -sS -D - -o /dev/null https://origin.example.net/page
Do not expose a protected origin hostname or bypass access controls merely to run the test. Use the origin-testing method designed for the hosting or CDN setup.
If origin returns max-age=300 and the public URL repeatedly returns an old edge HIT, that is enough to change the direction of the investigation. Check the CDN rule before touching WordPress or Nginx again.
Cache HIT, MISS, BYPASS, and Age
CDNs and reverse proxies use different response fields for cache status. Depending on the stack, you may see Age, Cache-Status, CDN-Cache-Status, X-Cache, or another vendor-specific field.
Request the same resource more than once:
curl -sS -D - -o /dev/null https://example.com/image.webp
curl -sS -D - -o /dev/null https://example.com/image.webp
A MISS followed by a HIT is strong evidence that the edge cached the representation. Increasing Age provides another clue. Neither proves what a particular browser has stored locally.
Why the same URL can have more than one cached response
max-age answers how long a stored representation can remain fresh. It does not fully define which requests are allowed to reuse that representation. The cache key and Vary participate in that decision.
For example:
Cache-Control: public, max-age=3600
Vary: Accept-Encoding
A cache can keep separate representations for requests whose relevant Accept-Encoding values differ. Another application might return:
Vary: Accept-Language
and maintain language-specific cached representations of the same URI.
This explains some apparently inconsistent tests: two requests use the same URL, but they are not necessarily eligible for the same stored response. During diagnosis, record Vary before concluding that an edge cache randomly ignored its TTL.
Purging CDN cache
A CDN purge invalidates edge objects according to that provider's mechanism. It does not rewrite a fresh response already stored in the visitor's browser. During an urgent deployment you can therefore purge the CDN, verify that the edge has the new CSS, and still have one browser render the old asset.
| Layer | What can control its TTL | Useful indicators | How to check or invalidate |
|---|---|---|---|
| Browser | max-age, validation directives, browser request behavior |
DevTools cache source, request headers, status, transferred size | DevTools, fresh profile, new versioned URL |
| CDN edge | s-maxage, max-age, CDN cache rules |
Age, cache status, Vary |
Repeated GET requests and CDN purge controls |
| Reverse proxy | Proxy cache configuration and upstream headers | Proxy status headers and origin comparison | Inspect proxy rules and approved bypass/origin path |
| Origin application | CMS, PHP, application cache, web-server headers | Direct origin response | Inspect application and server configuration |
"The cache is wrong" is too broad to debug. Determine which layer returned the representation first.
Why Does the Browser Still Send a Request When max-age Is Set?
Seeing a request in browser DevTools does not prove that the browser downloaded the full resource again. Once a cached response becomes stale, the browser can send a conditional request and receive 304 Not Modified instead of a new response body.
Fresh cache hit
While a cached response remains fresh, the browser can reuse it without contacting the server. DevTools may show a memory-cache or disk-cache source depending on the browser and situation.
For a fingerprinted stylesheet with a long TTL, that is exactly what you normally want: the URL identifies a known version, so another network round trip buys nothing while it remains fresh.
Stale response and conditional request
After freshness expires, validators can let the client ask whether its stored representation is still current. Common pairs are:
ETagwithIf-None-Match;Last-ModifiedwithIf-Modified-Since.
A GET might return:
HTTP/2 200
Cache-Control: public, max-age=300
ETag: "91a7-62f123abcd"
You can reproduce the validation request manually using the ETag value returned by your server:
curl -sS -D - -o /dev/null \
-H 'If-None-Match: "91a7-62f123abcd"' \
https://example.com/assets/app.css
If that validator still matches the current representation and the server supports conditional requests correctly, you can receive:
HTTP/2 304 Not Modified
The browser can then reuse the body it already has. An HTTP request happened, but the full stylesheet, script, image, or page did not need to be transferred again.
304 Not Modified is not a cache failure
A 304 often means revalidation is doing its job. Calling every request visible in the Network panel a cache miss loses the distinction between a full 200 transfer and a conditional validation round trip.
fresh response
reuse stored representation
stale response + validator
send conditional request
304 if unchanged
200 with new representation if changed
stale response without a usable validator
retrieve a new representation
Reload and DevTools can change what you observe
Manual reloads, force reloads, request Cache-Control directives, and the DevTools "Disable cache" option can change normal browser behavior. A test with DevTools open in one configuration may therefore differ from an ordinary visitor navigation.
When a request looks suspicious, inspect the request headers as well as the response. A browser sending Cache-Control: max-age=0 or no-cache during a reload is a different scenario from the server returning those directives in its response.
Check status code, transferred bytes, resource size, validators, and cache source together. The presence of a Network row by itself proves very little.
Why Do You See Multiple or Conflicting Cache-Control Headers?
Multiple Cache-Control fields usually mean that more than one component in the response chain is contributing policy. PHP can add one value, Nginx or Apache another, and a reverse proxy or CDN can change the result again.
You might receive:
Cache-Control: public, max-age=3600
Cache-Control: no-cache
or duplicate freshness directives:
Cache-Control: max-age=300
Cache-Control: max-age=86400
Do not assume that the last field simply wins. When the same freshness directive occurs more than once, caches are expected to use the first occurrence or treat the response as stale. When directives conflict, the more restrictive behavior should be honored. In the first example, no-cache prevents normal reuse without successful validation despite the presence of max-age=3600.
That makes duplicate headers a configuration problem even when one browser appears to "do the right thing." Another cache layer may take a conservative path and treat the response as stale.
- Request the public URL and record every relevant response header.
- Request the origin through an approved origin-testing path.
- Compare a static file with generated HTML.
- If possible, test while bypassing the CDN without changing unrelated rules.
- Review CMS or PHP-generated headers when only dynamic responses differ.
- Change one layer, repeat exactly the same request, and only then continue to the next layer.
| Observation | Likely area to investigate | Next check |
|---|---|---|
| Header is already wrong at origin | Application, PHP, web server, origin proxy | Compare static and dynamic origin URLs |
| Origin is correct, public response differs | Reverse proxy or CDN | Review edge rules, Age, and cache-status fields |
| Static file is correct, HTML is wrong | Application or page-cache path | Check PHP, CMS, and dynamic caching rules |
| HEAD is correct, GET is wrong | Method-specific application/intermediary behavior | Use GET as the control test before changing TTL |
| GET is correct but one browser remains old | Browser cache or service worker | Inspect DevTools and test a new asset URL or clean profile |
The temptation is to edit Nginx, the WordPress plugin, and the CDN in one pass. That destroys the evidence. Change one layer, repeat the same request, and compare the headers before touching the next one.
When Is a Long max-age Dangerous?
A long max-age becomes dangerous when content can change behind the same URL and users cannot safely continue using the old representation. A one-year TTL fits properly fingerprinted static assets; it is a poor blanket policy for an application.
HTML pages
HTML tells the browser which CSS, JavaScript, images, and application endpoints to use. Keeping outdated HTML fresh for too long can leave users referencing old asset versions even when the new deployment itself is correct.
Long CDN caching of public HTML can still work when invalidation, surrogate caching, and application behavior were designed for it. That is different from sending every browser max-age=31536000.
Unversioned CSS and JavaScript
Consider:
/assets/app.js
You replace the JavaScript on the server, leave the URL unchanged, and the existing response has:
Cache-Control: public, max-age=31536000
A browser can legitimately keep using the old representation while it remains fresh. Clearing FastCGI cache, WordPress cache, or the CDN does not fix that browser copy because the browser has no reason to ask those systems for app.js again.
The TTL is not broken here. The stable asset URL is the bug.
User-specific responses
Account pages, dashboards, carts, checkout responses, and APIs can depend on authentication, cookies, authorization, language, or user identity. Those responses need explicit caching design. A public shared-cache policy copied from static assets can expose the wrong state to another request.
private can permit private cache storage while preventing normal shared storage. no-store can be used when the response should not be intentionally stored by caches. The right directive depends on the content and application contract, not on a PageSpeed warning.
Authentication and private data
Do not begin with "how can I cache this for a year?" for sensitive responses. Begin with "can another request safely reuse this representation?" Only after that question has a clear answer should you choose freshness and storage directives.
| Scenario | Long max-age safe? | Why | Better approach when unsafe |
|---|---|---|---|
| Fingerprint CSS / JS | Usually yes | Changed bytes create a new URL | Keep content-addressed or versioned filenames |
| Unversioned JavaScript | Risky | New code can appear behind an old cached URL | Version the URL or shorten the TTL |
| HTML | Usually not as a blanket browser policy | Page content and asset references change | Use shorter freshness, revalidation, or controlled edge caching |
| Public image | Often | Safe when content is stable or URL changes on replacement | Version the image URL when replacing it |
| Account page | Not as a generic public shared response | Content can be user-specific | Use application-appropriate private or non-storage policy |
| Cart / checkout | Usually not as public shared cache | Response depends on session and state | Exclude unless the application explicitly supports safe shared caching |
| Private API response | Only with deliberate design | Authorization and variation can make reuse unsafe | Define explicit cacheability, variation, and privacy rules |
A performance audit can report a short lifetime for an asset. The same distinction matters when working through server-side Core Web Vitals diagnostics: a performance warning can identify a symptom, but it cannot tell whether your deployment overwrites that resource at the same URL, whether a WooCommerce response is personalized, or whether a CDN cache key separates variants correctly. Those decisions belong to the application and delivery architecture.
How Do You Change a Cache Policy Without Trapping Users on an Old Version?
Before assigning a very long max-age, make sure changed static assets receive a new URL. Versioning comes first; aggressive caching comes after it.
A build might produce:
app.abc123.js
After the JavaScript changes:
app.def456.js
The new HTML references the second file. A browser that still has app.abc123.js cached for a year can keep it without hurting the new deployment, because the new page no longer asks for that URL.
The same approach works for stylesheets:
site.8f31c2.css
site.f9d17a.css
The bytes behind a fingerprinted URL must stay stable. If your deployment regenerates site.8f31c2.css with different content, the fingerprint no longer serves its purpose and immutable becomes misleading.
Query-string versions are another approach:
/style.css?v=41
/style.css?v=42
Modern caches generally handle query components correctly, but filename fingerprinting often makes the deployment contract clearer: old URL means old bytes, new URL means new bytes.
A safe rollout sequence is:
- Verify that the build or CMS changes an asset URL whenever its contents change.
- Deploy a new version and confirm that HTML references the new URL.
- Request old and new asset URLs and confirm that each serves the expected content.
- Increase the freshness lifetime for versioned assets.
- Add
immutableonly when content at the URL will not be replaced. - Run another deployment and verify that another content change produces another URL.
A CDN purge remains useful for HTML, emergency invalidation, and operational cleanup, but it should not be the only mechanism keeping year-long browser-cached assets deployable. You do not control when every client revisits the CDN.
Fingerprinting also makes rollback easier. Old HTML can reference the old asset and new HTML can reference the new one, provided both immutable versions remain available for as long as pages might legitimately reference them.
How Can You Audit Cache-Control in 10 Minutes?
The fastest useful audit is to test representative URLs from each response type and record the headers that actually reach the client. One homepage request cannot tell you whether the site's caching policy is coherent.
Start with a small sample:
https://example.com/
https://example.com/article-or-product/
https://example.com/assets/app.css
https://example.com/assets/app.js
https://example.com/uploads/image.webp
https://example.com/assets/font.woff2
If the site has an API, logged-in area, WooCommerce cart, account page, or another personalized route, add a representative response from that group.
Use HEAD for a quick look, GET as the control test
A fast HEAD request is useful:
curl -I https://example.com/resource
For the response that really matters, especially HTML, PHP, WordPress, APIs, or CDN behavior that looks inconsistent, run GET and discard the body:
curl -sS -D - -o /dev/null https://example.com/resource
If HEAD shows the policy you expected but GET does not, stop tuning the TTL. First determine why the two methods take different application or intermediary paths.
A useful filtered GET is:
curl -sS -D - -o /dev/null https://example.com/resource \
| grep -iE 'HTTP/|cache-control|age|expires|etag|last-modified|vary|set-cookie|cache-status|x-cache'
Inspect the full response as well when the filter does not include your CDN's cache-status field.
- Check the homepage HTML.
- Check a second HTML page such as an article or product.
- Check one CSS file.
- Check one JavaScript file.
- Check one image.
- Check one font.
- Check an API or dynamic URL when present.
- Check a logged-in or personalized route where applicable.
- Record
Cache-Control. - Record
Agewhen exposed. - Record
Expires. - Record
ETagandLast-Modified. - Record
Vary. - Notice
Set-Cookie, but do not treat it as proof that caching is disabled. - Record the CDN or proxy cache-status field.
- Repeat one public GET and compare the response.
- Compare HEAD with GET when the result looks inconsistent.
- Compare public and origin responses when a safe origin-testing method exists.
- Check whether HTML and immutable assets accidentally receive the same policy.
- Verify that a changed versioned asset really receives a new URL.
- Look for duplicate
Cache-Controldirectives. - Do not use browser refresh as the only test.
- After every configuration change, repeat the same request before changing another layer.
Build a small cache matrix
| URL type | Cache-Control | Age / cache status | Validator / Vary | Expected policy? |
|---|---|---|---|---|
| Homepage HTML | Record actual value | Record if present | ETag, Last-Modified, Vary | Yes / No |
| CSS | Record actual value | Record if present | ETag, Last-Modified, Vary | Yes / No |
| JavaScript | Record actual value | Record if present | ETag, Last-Modified, Vary | Yes / No |
| Image | Record actual value | Record if present | ETag, Last-Modified, Vary | Yes / No |
| Personalized response | Record actual value | Check for shared caching | Vary, cookies, application-specific validators | Yes / No |
Reproduce one conditional request
If a resource returns an ETag, copy that validator and issue a conditional GET:
curl -sS -D - -o /dev/null \
-H 'If-None-Match: "ETAG-FROM-PREVIOUS-RESPONSE"' \
https://example.com/assets/app.css
A matching representation can return 304 Not Modified. That gives you a real validation test instead of inferring browser behavior from the presence of a Network request.
Use the eight-step Cache-Control troubleshooting method
- URL: identify the exact resource that behaves incorrectly.
- Response type: decide whether it is HTML, a static asset, API data, or personalized content.
- Origin: determine what the origin returns for a real GET.
- Edge: determine whether a CDN or reverse proxy changes, varies, or caches the response.
- Client: inspect what the browser received and whether it reused local state.
- Freshness: determine whether the stored response is fresh, stale, or being revalidated.
- Invalidation: identify how that resource changes: revalidation, purge, query version, fingerprinted filename, or another mechanism.
- Policy: only then decide whether its TTL should be shorter, longer, private, non-stored, or different between browser and shared cache.
If the origin is correct and the public response is not, editing WordPress cannot fix the edge policy. If public GET is correct but one browser still renders old CSS, another Nginx reload cannot fix the browser's stored response. If app.js changes while its URL stays fixed under a one-year TTL, repeatedly clearing caches treats the symptom while leaving the deployment problem untouched.
Once you can answer three questions — who cached this exact representation, how long it remains fresh there, and how its URL or stored copy is invalidated when content changes — choosing max-age becomes the easy part.


