A slow WooCommerce store usually gets reported as "the host is slow". That conclusion is cheap and often wrong. Before you migrate, find out which layer is actually costing you time.
The four layers — object cache, page cache, CDN, browser cache — are not interchangeable, and checking them out of order produces fixes that look like they work in your browser and do nothing for a customer with three items in a cart.
The order, and why
- Measure first, with the cache bypassed.
- Object cache and the database — the cost you cannot cache away.
- Page cache — does it hit, and does it know about WooCommerce cookies?
- CDN — static assets first; HTML at the edge only with cookie-aware rules.
- Browser cache — repeat visits and asset reuse.
The reasoning in one paragraph: the CDN and the browser cache change how fast bytes travel. If PHP spends two seconds assembling the page, moving the bytes faster still leaves two seconds before the first byte arrives. A page cache hides that cost, but only for requests it considers cacheable — anything carrying a cart cookie, or hitting cart, checkout or my-account, drops back to the uncached path. An object cache makes that uncached path cheaper, which is why it goes first: it decides what a cache miss actually costs.
Step 0 — measure with the cache bypassed
Use a timing template rather than eyeballing the browser. TTFB is the number that tells you whether the problem is PHP or delivery.
timing() {
curl -s -o /dev/null -w 'ttfb=%{time_starttransfer}s connect=%{time_connect}s total=%{time_total}s bytes=%{size_download} http=%{http_code}\n' "$1"
}
timing https://example.com/shop/
timing https://example.com/product/example-product/
Read it like this:
- TTFB high and total roughly equal to TTFB → the page is being built slowly. Backend.
- TTFB low and total much higher → the page is built fast and delivered slowly. Assets, network, or both.
Two things that ruin this measurement:
- You are logged in. Many cache layers skip logged-in users, and the admin bar can disable caching for you specifically. Test logged out, in a private window.
- The first request is not the second. Run each URL twice and compare. The gap between cold and warm tells you whether a page cache is doing anything at all.
Then measure the path you are not allowed to cache — the one a real customer hits when they have something in the cart:
curl -s -o /dev/null -w 'warm ttfb=%{time_starttransfer}\n' https://example.com/shop/
curl -s -o /dev/null -w 'with-cart ttfb=%{time_starttransfer}\n' \
-H 'Cookie: woocommerce_items_in_cart=1; woocommerce_cart_hash=test' \
https://example.com/shop/
If the first is 200 ms and the second is two seconds, that gap is your real problem, and it is not a CDN problem.
Step 1 — object cache and the uncached path
Is a persistent object cache actually in use?
wp eval 'var_dump( wp_using_ext_object_cache() );'
ls -l wp-content/object-cache.php
false and no drop-in means every request rebuilds everything from the database on its own. That is the normal state of a WordPress install and it is fine for a blog, less so for a shop with filters, variations and a lot of term meta.
Next, the usual suspects on the uncached path.
Oversized autoloaded options. Everything with autoload enabled is loaded on every request, cached or not:
wp db query "SELECT option_name, LENGTH(option_value) AS bytes
FROM $(wp db prefix)options
WHERE autoload IN ('yes','on')
ORDER BY bytes DESC
LIMIT 20;"
A handful of rows in the hundreds of kilobytes is worth investigating — a stale backup option, an unused page builder's settings, a log that got stored as an option.
Transients that pile up and never get cleaned:
wp transient list --format=count
A large number is not automatically a problem, but expired transients that are never garbage collected and are not backed by a persistent cache will keep hitting the options table.
Repeated queries. If the same query runs on every request, no page cache setting fixes it — a persistent object cache absorbs the repeat, and an index or a query change removes it. If WP-CLI is available, the profile package gives a stage-by-stage breakdown without editing files:
wp package install wp-cli/profile-command
wp profile stage --url=https://example.com/shop/
What you are looking for on a WooCommerce category or product page: work that scales with the number of products on the page, and queries that reappear identically on every request.
Then enable a persistent object cache, measure again with the same curl commands, and put a flush step in your deploy. Without that flush, a stale object cache keeps serving old data and you will spend an afternoon debugging code that was already correct.
Step 2 — page cache
First question: is it hitting?
curl -sI https://example.com/shop/ | grep -iE 'cache-control|age|x-cache|expires|set-cookie'
Header names differ between setups, but the shape of a hit is the same: a cache-related header with an age, and TTFB dropping sharply between the first and second request. A Set-Cookie on an anonymous page request is the opposite signal — it usually means the response was built for a specific visitor and must not be shared.
Second question, and this is the one that catches WooCommerce sites: does the cache know which requests must never be served from it?
At minimum: cart, checkout, my-account, and any request carrying woocommerce_items_in_cart, woocommerce_cart_hash or a wp_woocommerce_session_* cookie. WordPress has a standard constant for opting a template out:
// In a template that must never be cached, before any output.
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
It can also be set from a hook for the relevant endpoints:
add_action( 'template_redirect', function () {
if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) {
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
}
} );
Page caches that respect the constant will skip those pages. If yours does not, exclude the URLs in its configuration instead.
Two more things:
- Do not run two page caches. An old plugin plus a host-level cache means purges go to one of them, and you get a page that is stale for some visitors and fresh for others.
- The cart count in the header is the usual reason people disable the page cache entirely. Cache the page and load the cart fragment separately, or accept a count that updates on the cart page.
Step 3 — CDN
A CDN does two different jobs and it is worth separating them.
Static assets — images, CSS, JS, fonts. This is the straightforward win: long TTLs, hashed filenames, bytes served from near the visitor.
HTML at the edge — a much bigger win and a much bigger risk on WooCommerce. If the edge does not understand the cart cookies from Step 2, you will serve one customer's cart state to another, or a logged-out header to a logged-in buyer. If you cannot write and test those rules, keep HTML off the edge and let the origin page cache handle it.
Check what is actually returned for an asset:
curl -sI https://example.com/wp-content/uploads/example.jpg | grep -iE 'cache-control|age|etag|expires'
And measure from somewhere near your customers, not from your laptop. An edge in the region where you develop tells you nothing about the experience in the region where you sell.
One rule about ordering: do not put a CDN in front of a slow origin as a fix. It makes the slowness global and adds a cache to purge.
Step 4 — browser cache
Last, and cheapest. Two policies:
# Hashed static assets: cache hard.
location ~* \.(css|js|jpg|jpeg|png|webp|avif|svg|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML: let the server and the page cache decide.
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
The catch with long TTLs: if your asset filenames are not hashed, a one-year TTL will pin a stale stylesheet after every deploy. Change the version argument so the URL changes when the file changes:
wp_enqueue_style(
'theme-main',
get_stylesheet_directory_uri() . '/assets/main.css',
[],
filemtime( get_stylesheet_directory() . '/assets/main.css' )
);
That is the whole point of this layer — it only affects repeat visits and asset reuse, so it is the last thing to fix and the least likely to explain "the site is slow".
Symptom → layer
| What you see | Most likely layer | First check |
|---|---|---|
| Slow everywhere, including wp-admin | Object cache / database / host |
wp profile stage, autoloaded options |
| Fast anonymously, slow with items in cart | Uncached path |
wp_using_ext_object_cache(), repeated queries |
| Fast TTFB, slow page in the browser | Assets / CDN | waterfall, asset headers |
| Slow only for visitors far from the server | CDN / network | measure from that region |
| Slow on repeat visits, fine on first load | Browser cache |
Cache-Control on CSS/JS/images |
| One page slow, the rest fine | That page's queries or a shortcode | profile that URL only |
Checklist before you blame the host
- [ ] Measured logged out, twice, cold and warm.
- [ ] Measured with a cart cookie to see the uncached path.
- [ ] Checked whether a persistent object cache is in use and whether it is flushed on deploy.
- [ ] Looked at the largest autoloaded options.
- [ ] Confirmed the page cache actually hits and excludes cart, checkout and my-account.
- [ ] Confirmed only one page cache is running.
- [ ] Verified asset cache headers, and that filenames or version arguments change on deploy.
- [ ] Measured from a region near the customers.
Migrating hosts is a legitimate fix for a slow database server or a noisy neighbour. It is not a fix for a query that runs on every request or a page cache that never hits. Measure first, change one layer, measure again.
If the diagnosis keeps landing on "the origin is slow and nobody on this team wants to own the server", handing the build to someone else is a legitimate option — one of several, alongside hiring, or learning the stack yourself. AumCreate deploys WordPress sites onto hosting the client owns and hands over a one-page guide to editing content afterwards: setup. Run the measurements above either way — they are what tell the next person which layer to fix, and they are what stop you paying twice for the same slowness.
Top comments (0)