Response header · non-standard
< X-Cache: HITX-Cache Header
Learn how the X-Cache header indicates cache hit or miss status from CDNs and proxies. Debug caching issues and verify CDN configuration with this header.
- Direction
- Response
- Category
- Diagnostics
- Status
- Non-standard: CDN-specific; value format varies by vendor
On this page
TL;DR:
X-Cachetells you whether a CDN or proxy cache served the response (HIT) or had to fetch it (MISS). It isn’t standardized, so each vendor defines its own values: a FastlyHITcan be stale or generated at the edge, and itsMISScan be a pass-through request. Look up the docs for whichever layer set it. Cloudflare usesCF-Cache-Statusinstead, and the standard equivalent isCache-Status.
What is X-Cache?
X-Cache has no standard syntax and no agreed set of values. A CDN might set it, and so might your own reverse proxy, under the same name. So the first job is working out which component wrote the value you’re looking at. RFC 9111 defines how HTTP caches behave, but X-Cache isn’t part of it.
How X-Cache Works
A cache writes its decision into a response field. That field reports what happened; it doesn’t instruct anything. If your application sets X-Cache: HIT, nothing gets cached and browsers behave exactly as before.
Responses often pass through several caches. A MISS at one layer only tells you about that layer. Another cache further upstream may have supplied the body without the origin generating anything new.
Common Values
In nginx, $upstream_cache_status can be MISS, BYPASS, EXPIRED, STALE, UPDATING, REVALIDATED or HIT. nginx only puts these in an X-Cache header if you configure it to.
EXPIRED and REVALIDATED are different outcomes. nginx revalidates expired entries with conditional requests only if you turn on proxy_cache_revalidate on, which defaults to off. Report expired entries as EXPIRED unless the origin actually confirmed them with a conditional request.
Real-World Examples
Fastly documents HIT and MISS as simplified cache states. It reports PASS as MISS. Synthetic responses generated at the edge, stale hits and hits served during background revalidation all show up as HIT. So a Fastly HIT doesn’t mean you got fresh content from the origin.
Here’s an example Fastly value (headers on this page are trimmed examples):
X-Cache: HIT, MISS
With Fastly shielding, the first token is the shield and the second is the edge. This one means the shield hit and the edge missed. When the edge token is HIT, the shield token may just be left over from when the edge filled its cache, not the result of a fresh lookup at the shield.
And a Cloudflare value:
CF-Cache-Status: REVALIDATED
Cloudflare reports this when the origin confirms, through a conditional request, that its cached copy is unchanged. An X-Cache header on a site behind Cloudflare may come from some other component, so check before reading it as Cloudflare’s verdict.
Implementation
For an existing nginx proxy cache, add this to the location that uses proxy_cache:
add_header X-Cache $upstream_cache_status always;
This only reports the cache decision; it doesn’t turn caching on. If the upstream sends its own X-Cache, either give your layer’s header a different name or hide the upstream one in that location:
proxy_hide_header X-Cache;
add_header X-Cache $upstream_cache_status always;
The proxy_hide_header directive controls which upstream response fields nginx passes on.
For a complete local setup, run an origin on port 3000 that serves public responses. Then pick a writable cache directory and put this in the nginx http block:
proxy_cache_path /tmp/nginx-public-cache levels=1:2
keys_zone=public_assets:10m inactive=10m;
server {
listen 8080;
server_name localhost;
location /public/ {
proxy_pass http://127.0.0.1:3000;
proxy_cache public_assets;
proxy_cache_valid 200 60s;
proxy_cache_revalidate on;
proxy_cache_bypass $http_authorization $http_cookie;
proxy_no_cache $http_authorization $http_cookie;
proxy_hide_header X-Cache;
add_header X-Cache $upstream_cache_status always;
}
}
The directory, zone size, inactivity window and fallback lifetime are all example values. proxy_cache_path creates the cache storage and its shared metadata zone, and proxy_cache selects it. proxy_cache_valid sets a lifetime only when the upstream hasn’t sent one that takes precedence. Private or no-store responses stay out of the cache.
The two request checks do different jobs. proxy_cache_bypass stops nginx from serving an existing cached response, and proxy_no_cache stops it from storing the response it fetches. This config applies both to requests with cookies or an Authorization header. With only the storage check, a request you meant to bypass would still be served whatever was already cached. Keep this route to public data, and check the upstream’s Cache-Control and Set-Cookie headers before you test.
Run nginx -t before activating the config. It checks syntax and tries to open the files the config references. Fix any path or permission errors it reports, then run nginx -s reload. Both commands are in the nginx command-line switches docs.
Analyzing Cache Performance
To get a hit ratio, count decisions in the cache’s own logs or analytics. One response header can’t give you a service-wide ratio, and a hit counter on one node isn’t a global count. With shielding, a single client request can log both an edge miss and a shield hit.
Log nginx’s cache decision next to the status and upstream timing. log_format goes in http; select the format in the cached server or location:
log_format cache_debug '$request_method $uri status=$status '
'cache=$upstream_cache_status upstream=$upstream_addr '
'upstream_time=$upstream_response_time';
access_log /tmp/nginx-cache-debug.log cache_debug;
These variables are documented in nginx’s upstream module. An empty cache status can mean the request never used the proxy cache, so count those separately from MISS. Note the time window and location you sampled, too: a different mix of routes can shift the ratio even when the cache config hasn’t changed.
On CloudFront, use the x-edge-response-result-type log field. It separates Hit, Miss and RefreshHit, while the final x-edge-result-type can say Error if delivery failed after the cache decision. Counting every final error as an origin fetch skews your numbers.
Getting X-Cache right
Read freshness from Age and Cache-Control, not from X-Cache. And a MISS doesn’t mean the response will be stored this time; that depends on the response’s directives and the cache’s configuration.
Testing Cache Behavior
Use a real URL from your deployment:
curl -sS -D - -o /dev/null https://example.com/style.css
curl -sS -D - -o /dev/null https://example.com/style.css
Capture the full set of headers, including Vary, the cache status and any serving-node ID. The first request may already be a HIT if another client filled the cache before you.
Against the local config, request the same route twice, then once more with a cookie:
curl -sS -D - -o /dev/null http://localhost:8080/public/config
curl -sS -D - -o /dev/null http://localhost:8080/public/config
curl -sS -D - -o /dev/null \
-H 'Cookie: session=demo' http://localhost:8080/public/config
The cookie request tests the bypass rule. The second plain request should be a HIT if the first response was stored. If both say MISS, look at the origin’s headers and the nginx error log, and check that the worker can write to /tmp/nginx-public-cache. Hard-coding X-Cache: HIT would hide the problem, not fix it.
Debugging Cache Issues
A cache-busting query string only works if the query string is part of the cache key. Cache-Control: no-cache on the request asks for revalidation, which isn’t the same as skipping the cache. Check the operator’s cache rules before you assume either trick reached the origin.
RFC 9211 defines Cache-Status as the standard diagnostic field. Its values don’t map one-to-one onto any vendor’s X-Cache values.
nginx’s default cache key includes $scheme$proxy_host$request_uri, so the query string is part of it. CloudFront’s default key leaves query strings out, though a cache policy can add specific parameters. That’s why a ?debug=... suffix busts the cache on one deployment and does nothing on another. Check the nginx key or the CloudFront policy instead of guessing.
If a response varies by language, test with each Accept-Language value and check Vary. If it varies by login, make sure authenticated requests skip both storage and lookup. A HIT on the wrong representation is a correctness bug, however good it looks in the hit ratio.
For a same-origin check in the browser, log the policy and both common diagnostic headers together:
const response = await fetch('/public/config', { cache: 'no-cache' });
console.table({
status: response.status,
xCache: response.headers.get('x-cache'),
cloudflare: response.headers.get('cf-cache-status'),
age: response.headers.get('age'),
policy: response.headers.get('cache-control'),
vary: response.headers.get('vary'),
});
await response.arrayBuffer();
For a cross-origin API, these headers have to be exposed through CORS before JavaScript can read them, so a null here isn’t a MISS. The Network panel shows headers that Fetch hides from scripts. Compare the two views instead of adding headers to your app just so the console shows something.
With the local nginx config, investigate with three separate requests: a normal GET that can fill the cache, an identical GET that can be served from it, and a GET with a cookie that should bypass it. Log the status and cache result for each. If the cookie request says HIT, find out which location actually handled it and whether its bypass directives were inherited or overridden. If normal requests keep saying BYPASS, look at the cookies and Authorization headers they carry before you change the cache key.
Keep authenticated requests out of the public cache while you debug. Removing the bypass checks to get a HIT changes what the cache is allowed to reuse, which is a much bigger change than a debugging tweak. Change one thing at a time and rerun the same three requests, so you know which change caused which result.
Related Headers
Frequently asked questions
What is X-Cache?
X-Cache is a vendor-defined response field that reports cache decisions. Its values and meaning depend on the cache that emits it.
What does X-Cache: HIT mean?
Consult the emitting vendor. Fastly reports cached hits, stale hits, and edge-generated synthetic content as HIT, so it does not universally mean fresh content from an origin cache.
What does X-Cache: MISS mean?
A MISS describes that cache layer. Fastly also reports PASS as MISS, and an upstream shield can satisfy an edge miss without fetching from the origin.
Is X-Cache a standard header?
X-Cache is non-standard. RFC 9211 defines Cache-Status as a standardized diagnostic field; Cloudflare uses CF-Cache-Status.
Sources
Related
CDN Not Caching: cf-cache-status DYNAMIC, BYPASS, MISS
CDN not caching? Read cf-cache-status DYNAMIC, BYPASS, MISS or X-Cache: Miss from cloudfront, then fix Set-Cookie, Cache-Control, Vary and cache keys.
CDN (Content Delivery Network)
A CDN is a network of edge servers that cache and serve responses near users. See how cache hits show up in headers and what CDNs do not cache by default.
X-Response-Time
Learn how the X-Response-Time header indicates server processing time in milliseconds. Useful for performance monitoring and debugging slow requests.
Accept-Ranges Header
Learn how the Accept-Ranges header tells clients whether your server supports partial content requests (byte ranges) for efficient downloads and streaming.