# Which cache layer fixes which slowdown, judged from the response headers

The complaint never says which part is slow. It arrives as 'the site is slow', and the first reaction is to switch on some caching and see whether it feels better. That works often enough to be a trap: it hides the layer that was actually the problem, and the next person who has to debug the site has no idea which cache to clear.

Get the response to describe itself first.

## Read the response before touching a cache

```bash
for i in 1 2 3; do
  curl -s -o /dev/null \
    -w 'ttfb=%{time_starttransfer} total=%{time_total} bytes=%{size_download}\n' \
    https://example.com/
done
```

Send it more than once. If a full-page cache is doing its job, the later requests usually differ from the first, because the cache key repeats. If every request sits at the same TTFB with the same byte count, the HTML is being assembled from PHP and the database each time.

Then the headers:

```bash
curl -sI https://example.com/ | grep -iE 'cache-control|age|x-cache|vary|etag'
```

`Age` above zero means something in front of PHP served a stored copy. `X-Cache` is not standard, but plenty of setups emit `HIT` or `MISS` there. `Cache-Control: no-cache` with an `ETag` on the HTML means the browser revalidates each load, which is correct for pages that change.

Run both commands again with a logged-in cookie, and from a machine in another region. Where the numbers diverge is where the layer lives.

| What you observe | Layer that can act on it | How to confirm | What it will not fix |
| --- | --- | --- | --- |
| HTML TTFB high, identical from every region, anonymous visitors | full-page cache | second request drops, `X-Cache` flips to HIT | cart, checkout, logged-in, wp-admin |
| Slow only when logged in, in admin, or on cart and checkout | persistent object cache | `redis-cli monitor` shows reads and writes during a page load | asset weight, network distance |
| Fast next to the server, slow for distant visitors | CDN / edge | `Age` present from a remote host, assets served from the edge host | a slow origin: a miss still waits for PHP |
| Repeat visits re-download the same CSS, JS and images | browser cache headers | `Cache-Control` on the asset response | the first visit, and the HTML |

## Full-page cache: the HTML is rebuilt on every request

This is the nginx version, because the mechanics stay visible. Plugin-based page caches do the same job with less control over the key.

```nginx
# http context
fastcgi_cache_path /var/cache/nginx/wp levels=1:2 keys_zone=wp:100m
                   inactive=60m max_size=2g use_temp_path=off;

map $http_cookie $wp_bypass_cookie {
    default 0;
    '~*wordpress_logged_in'   1;
    '~*woocommerce_cart_hash' 1;
    '~*wp-postpass'           1;
}
```

```nginx
server {
    set $wp_bypass_uri 0;
    if ($request_uri ~* '^/(wp-admin/|wp-json/|cart/|checkout/|my-account/)') {
        set $wp_bypass_uri 1;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php-fpm.sock;   # match your pool
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

        fastcgi_cache wp;
        fastcgi_cache_key '$scheme$request_method$host$request_uri';
        fastcgi_cache_valid 200 301 302 10m;
        fastcgi_cache_lock on;
        fastcgi_cache_bypass $wp_bypass_cookie $wp_bypass_uri;
        fastcgi_no_cache     $wp_bypass_cookie $wp_bypass_uri;
        add_header X-Cache $upstream_cache_status always;
    }
}
```

Two things bite people here. The exclusion list has to match the actual paths on the site: `/checkout/` in a shop that uses `/checkout-2/` is a cache serving a stranger's session page to the next visitor. And `add_header` is inherited all-or-nothing per level, so if the `location` block already has one, the server-level headers vanish from that response — keep `X-Cache` in the same block as the others.

Invalidation is the other half. `fastcgi_cache_purge` is a third-party module, not something you get for free. Without it, expiry is your only invalidation, so either keep `fastcgi_cache_valid` short or delete the cache directory from a publish hook:

```bash
find /var/cache/nginx/wp -type f -delete
```

## Object cache: fast for anonymous visitors, slow for everyone signed in

A full-page cache is bypassed exactly where logged-in traffic lives, which is where the database work piles up. First confirm a persistent object cache even exists:

```bash
ls -l wp-content/object-cache.php
```

No drop-in, no persistent object cache, whatever a settings screen claims. Install one that talks to Redis or Memcached, then verify it is actually being used instead of trusting the green notice:

```bash
redis-cli -h 127.0.0.1 -p 6379 monitor
```

Load a page in another terminal and watch for read and write commands tied to your key prefix. If the page renders and nothing appears, the drop-in is not connected.

```bash
redis-cli info memory | grep used_memory_human
redis-cli config get maxmemory-policy
```

If Redis is shared with anything that expects keys to survive, an eviction policy that drops keys under pressure will drop those too — WooCommerce sessions among them. That shows up as random logouts, not as a slow page, and it is easy to misread as a caching win.

An object cache repeats expensive queries; it does not make one cheap. A page that takes two seconds to build because of a badly indexed meta query will take two seconds on a cold cache, and the cache will spend its memory holding the result. Look at the query before adding a layer in front of it.

## CDN: fast next to the server, slow everywhere else

If TTFB from a machine near the origin is fine and TTFB from another continent is not, the origin is healthy and the distance is the problem. Put the static assets on the edge first — that is the cheapest win and it cannot break sessions. HTML at the edge needs rules: exclude responses that set cookies, respect `Vary: Cookie` if you cache HTML at all, and purge on deploy.

A CDN in front of a slow origin mostly serves slow pages from more locations. Fix the origin response first, then measure again from the remote host and look for a non-zero `Age` on a repeat request.

## Browser cache: the same files downloaded on every visit

```nginx
location ~* \.(?:css|js|woff2|png|jpe?g|webp|avif|svg)$ {
    expires 30d;
    access_log off;
}
```

Long lifetimes are safe for filenames that change when the content changes. WordPress appends `?ver=` to enqueued assets, and browsers treat a different query string as a different URL, so the version bump does bust the browser copy even though intermediaries may ignore query strings entirely. Do not set a long lifetime on HTML, and do not set `immutable` on anything whose URL stays the same across releases.

This layer only helps the second visit. If the complaint is about a first load, it is the wrong place to look.

## Order of work

Measure with the curl loop, fix the origin query or PHP work if the object cache shows nothing to cache, then enable the full-page cache with the exclusions, then the object cache for the paths the page cache skips, then the edge, then the asset headers. Run the same curl loop at the end. If the numbers did not move, you changed the wrong layer, and at least you know which one it was not.

More WordPress operations notes live at https://aumcreate.com/blog?utm_source=hashnode&utm_medium=content&utm_campaign=hashnode-cache-layers-which-slow.
