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

Source: https://howhttpworks.com/headers/x-cache
Last reviewed: 2026-10-05

> **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](https://www.rfc-editor.org/rfc/rfc9111) 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`](https://nginx.org/en/docs/http/ngx_http_upstream_module.html#var_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`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cache_revalidate), which defaults to `off`. Report expired entries as `EXPIRED` unless the origin actually confirmed them with a conditional request.

## Real-World Examples

[Fastly documents](https://www.fastly.com/documentation/reference/http/http-headers/X-Cache/) 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):

```http
X-Cache: HIT, MISS
```

With [Fastly shielding](https://www.fastly.com/documentation/guides/concepts/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:

```http
CF-Cache-Status: REVALIDATED
```

[Cloudflare](https://developers.cloudflare.com/cache/concepts/cache-responses/) 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`:

```nginx
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:

```nginx
proxy_hide_header X-Cache;
add_header X-Cache $upstream_cache_status always;
```

The [`proxy_hide_header` directive](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_hide_header) 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:

```nginx
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`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#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](https://nginx.org/en/docs/switches.html) 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:

```nginx
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](https://nginx.org/en/docs/http/ngx_http_upstream_module.html#variables). 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`](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/standard-logs-reference.html) 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](https://howhttpworks.com/headers/age) and [Cache-Control](https://howhttpworks.com/headers/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:

```bash
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:

```bash
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](https://www.rfc-editor.org/rfc/rfc9211) 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](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cache_key) or the [CloudFront policy](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/understanding-the-cache-key.html) 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:

```javascript
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

- [Cache-Control](https://howhttpworks.com/headers/cache-control)
- [Age](https://howhttpworks.com/headers/age)
- [ETag](https://howhttpworks.com/headers/etag)
