How HTTP Works

Response header · non-standard

< X-Cache: HIT

X-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.

Reviewed 7 min readintermediate6 sourcesTry itMarkdown
Direction
Response
Category
Diagnostics
Status
Non-standard: CDN-specific; value format varies by vendor
On this page

TL;DR: X-Cache tells 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 Fastly HIT can be stale or generated at the edge, and its MISS can be a pass-through request. Look up the docs for whichever layer set it. Cloudflare uses CF-Cache-Status instead, and the standard equivalent is Cache-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.

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

  1. Cloudflare Cache Documentationdevelopers.cloudflare.com
  2. MDN Web Docs: HTTP Cachingdeveloper.mozilla.org
  3. RFC 9111: HTTP Cachingrfc-editor.org
  4. Fastly: X-Cachefastly.com
  5. Fastly: Shieldingfastly.com
  6. nginx: Upstream cache statusnginx.org

Keep going

Browse /search