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
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:
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.
# 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;
}
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:
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:
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:
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.
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
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.
About the author
- AumCreate is a Singapore-based WordPress development studio building custom websites, ready-made templates, and free open-source plugins. We work on WordPress development, WooCommerce, multilingual websites, SEO, and AI search visibility. Here we share practical tutorials, engineering decisions, lessons learned, and technical insights from building and maintaining real WordPress products. Website: https://aumcreate.com GitHub: https://github.com/aumcreate
Comments
Join the discussionNo comments yet. Be the first to comment.