WooCommerce Speed: Why Product Pages Crawl and How to Fix Them
Fix WooCommerce speed properly: measure TTFB vs LCP, clear autoload and Action Scheduler bloat, kill cart fragments and cache product pages without breaking
A client came to us in late 2025 with a catalogue of about 12,000 SKUs and a product page that took 4.2 seconds to return the first byte. Not to paint. To return HTML. Query Monitor counted 1,870 database queries on a single product template, and the wpactionscheduleractions table had just over 3 million rows in it, most of them completed woocommercerunproductattributelookupupdatecallback jobs that nobody had ever cleaned up.
They had already bought a “speed optimisation” plugin, enabled four layers of minification, and lazy loaded every image on the site including the one that was the Largest Contentful Paint. None of it helped, because none of it touched the actual problem. WooCommerce speed problems are almost always server-side and almost always database-shaped. The front-end tooling industry has trained everybody to look at the wrong half of the waterfall.
Here’s what actually goes wrong on product pages, in the order we check it, with the fixes we’ve shipped.
- Split your measurement into TTFB and render. If TTFB is above roughly 600ms on an uncached product page, no amount of CSS work will save you: the fix is PHP, MySQL or object caching.
- The three most common database killers are a bloated
wpoptionsautoload set (anything over about 800KB is trouble), an uncleaned Action Scheduler table, and awpwoocommerce_sessionstable that has grown to millions of rows because WP-Cron never fired. wc-cart-fragments.jsfires an uncacheable?wc-ajax=getrefreshedfragmentsrequest on every single page view. Dequeue it outside cart and checkout and read the cart count from the cookie instead.- Full page caching works fine with WooCommerce if you bypass on
woocommerceitemsincartandwoocommercecart_hashcookies only, not on the session cookie, which would knock out most of your traffic. - Variable products with more than about 50 variations embed hundreds of kilobytes of JSON in the page. Lower
woocommerceajaxvariation_thresholdor restructure the product.
Measure the two halves separately
Before you change anything, get two numbers for the same product URL: time to first byte with the page cache bypassed, and LCP in the field. They have completely different causes and completely different fixes. Mixing them up is how people end up minifying CSS on a site whose real problem is a missing index.
For TTFB, hit the URL with a cache-busting query string and no cookies:
# Server-side time only, cache bypassed, repeat 5 times
for i in $(seq 1 5); do
curl -s -o /dev/null -w "%{time_starttransfer}\n" \
"https://example.com/product/widget/?nocache=$RANDOM"
done
A healthy WooCommerce product page on PHP 8.3 with a warm object cache returns in 150 to 350ms. Between 350 and 600ms you have headroom to find. Above 600ms something is structurally wrong. Above 2 seconds you are almost certainly looking at a query count in the four figures.
Then install Query Monitor on staging, log in as admin, and look at the Queries panel sorted by component and by time. You want the query count and the slowest individual query. On a well-behaved product page we expect 60 to 120 queries. We have seen 1,800. The difference is never the theme’s CSS.
One warning about admin-logged-in profiling: your own page loads skip the page cache and include admin bar work, so they are slower than reality for anonymous visitors but more representative of the uncached path, which is exactly the path you need to fix. Don’t dismiss the numbers because “visitors get the cached version”. Googlebot, every first-time visitor, and everyone with something in their cart get the uncached version too.

The database is where woocommerce performance goes to die
Autoloaded options
Every WordPress request loads the entire autoload option set into memory before anything else happens. WooCommerce plus a handful of extensions can push that past a megabyte, and once it does you are paying serialisation cost on every request including AJAX calls. Check it:
-- WP 6.6+ uses 'on'/'auto'/'auto-on' as well as the legacy 'yes'
SELECT optionname, LENGTH(optionvalue) AS bytes
FROM wp_options
WHERE autoload IN ('yes','on','auto','auto-on')
ORDER BY bytes DESC
LIMIT 20;
SELECT ROUND(SUM(LENGTH(optionvalue))/1024) AS totalkb
FROM wp_options
WHERE autoload IN ('yes','on','auto','auto-on');
Under 500KB is fine. Over 800KB, start deleting. The usual offenders are abandoned plugins that left behind a serialised log, licence check caches, and dynamic data stored as an autoloaded option instead of a transient. Flipping a single 400KB row to autoload = 'off' has taken 80ms off TTFB for us on more than one site.
Action Scheduler
WooCommerce’s job queue lives in wpactionscheduleractions and wpactionschedulerlogs. The default retention is 30 days, which sounds reasonable until you realise a busy store generates tens of thousands of actions a day and the cleanup job runs through WP-Cron, which on many sites is quietly broken. When scheduled tasks stop firing, nothing gets purged. The WHERE status = 'pending' ORDER BY scheduleddategmt query that runs on admin and some front-end requests then starts scanning millions of rows.
wp action-scheduler status
wp action-scheduler clean --status=complete,failed,canceled --before='2 weeks ago' --batch-size=500
Then shorten retention in code so it never comes back:
// Keep a week of history instead of a month
addfilter( 'actionschedulerretentionperiod', function () {
return WEEKINSECONDS;
} );
If the cleanup isn’t running at all, fix the underlying scheduler before you fix anything else. We wrote up the full diagnosis in WordPress Cron Is Not Cron, and on commerce sites a real system cron hitting wp-cron.php with DISABLEWPCRON set is not optional.
Sessions
wpwoocommercesessions is the quietest disaster in the stack. Woo creates a session row for anyone who touches the cart, and the woocommercecleanupsessions job deletes expired ones daily. Same dependency as above: no cron, no cleanup. We have opened tables with 4 million rows on stores doing 50 orders a week, mostly bot sessions.
wp cron event run woocommercecleanupsessions
wp db query "SELECT COUNT(*) FROM wpwoocommercesessions;"
If WP-CLI still feels like a foreign country, this walkthrough covers the basics without assuming you live in a terminal.
Object caching
If you are not running a persistent object cache, do that before anything on this list. Redis with the Redis Object Cache drop-in turns hundreds of repeated option and term lookups into a handful of socket reads. On a mid-size catalogue we routinely see uncached product TTFB drop from 900ms to around 400ms from that change alone. The caveat: if your autoload set is still a megabyte, Redis will faithfully transfer that megabyte on every request. Fix the options table first, or you will just move the bottleneck onto the network.
Cart fragments are a per-pageview PHP tax
Open the network tab on any default WooCommerce store and load a blog post, a contact page, the homepage. You’ll see ?wc-ajax=getrefreshedfragments. That request boots WordPress, boots WooCommerce, initialises the session, and returns a snippet of mini-cart HTML on every page for every visitor. It can never be cached because it’s per-session. On shared hosting we’ve clocked it at 600 to 1,100ms, and because it runs in parallel with your assets it eats connection budget and PHP workers at exactly the moment the page is trying to render.
Kill it outside cart and checkout:
addaction( 'wpenqueue_scripts', function () {
if ( iscart() || ischeckout() ) {
return;
}
wpdequeuescript( 'wc-cart-fragments' );
}, 99 );
The trade-off is real: your header cart count will show whatever was cached in HTML. Fix that in about ten lines of JavaScript. WooCommerce already sets a cookie with the item count:
// woocommerceitemsin_cart is set when the cart is non-empty
document.addEventListener('DOMContentLoaded', () => {
const match = document.cookie.match(/woocommerceitemsin_cart=(\d+)/);
const count = match ? parseInt(match[1], 10) : 0;
const badge = document.querySelector('[data-cart-count]');
if (badge) {
badge.textContent = count;
badge.hidden = count === 0;
}
});
Where this advice does not apply: stores with an AJAX add-to-cart on archive pages that rely on fragment refresh to update the mini cart contents, not just the count. There you keep fragments on shop and category templates and dequeue everywhere else.
Page caching without breaking the cart
The received wisdom is that you cannot full-page cache WooCommerce. You can. You just have to bypass precisely. Most people bypass far too aggressively, usually on the session cookie, which Woo sets for a large share of visitors and which therefore sends almost everybody down the uncached path.
The cookies that genuinely mean “this response is personal” are woocommerceitemsincart and woocommercecarthash, plus wordpressloggedin*. In Nginx with FastCGI cache:
map $httpcookie $woonocache {
default 0;
"~*wordpressloggedin_" 1;
"~*woocommerceitemsin_cart" 1;
"~*woocommercecarthash" 1;
"~*wpwoocommercesession_" 0; # deliberately NOT a bypass
}
location ~ \.php$ {
fastcgicachebypass $woo_nocache;
fastcginocache $woo_nocache;
fastcgi_cache WORDPRESS;
fastcgicachevalid 200 10m;
}
Then exclude /cart/, /checkout/, /my-account/ and anything with ?wc-ajax= or add-to-cart= by path. That’s the whole configuration. Everything else, including every product page for a visitor with an empty cart, serves from cache in 20 to 60ms.
If you’re on LiteSpeed, its ESI implementation lets you keep the page cached and punch a hole for the mini cart, which is the better answer when your header genuinely needs live cart contents. On Cloudflare, APO respects the Woo cookies, but verify it yourself with curl -I rather than trusting the dashboard.
What’s actually heavy on the product template
Once TTFB is under control, the render side has three predictable problems.
The LCP image is lazy loaded. Aggressive optimisation plugins, and sometimes the gallery markup itself, put loading="lazy" on the main product image. That defers the single most important request on the page by a full round trip. Force the first gallery image eager and prioritised:
addfilter( 'woocommercesingleproductimagethumbnailhtml', function ( $html ) {
static $is_first = true;
if ( ! $is_first ) {
return $html;
}
$is_first = false;
$html = str_replace( ' loading="lazy"', '', $html );
return str_replace( '<img ', '<img fetchpriority="high" ', $html );
}, 10 );
Gallery scripts you don’t use. zoom, flexslider and photoswipe ship with the single product template and total roughly 100KB of JavaScript. If your design shows a static image with thumbnails below, remove the theme support and the dependencies drop out:
addaction( 'aftersetup_theme', function () {
removethemesupport( 'wc-product-gallery-zoom' );
removethemesupport( 'wc-product-gallery-lightbox' );
// Keep 'wc-product-gallery-slider' only if you actually slide
} );
Woo block styles and page builder CSS on every route. wc-blocks-style.css is not small, and most themes enqueue the full WooCommerce stylesheet site-wide including on pages with no commerce output. Scope it. The broader pattern of getting CSS and JS out of the critical path is covered in Eliminating Render-Blocking Resources, and it applies identically here.
Theme choice matters more than people admit at this stage. A product template that renders in 12 queries and ships 40KB of CSS beats one that renders in 90 queries and ships 400KB, and no plugin fixes that gap. If you’re rebuilding anyway, start from something with a lean WooCommerce template set, which is a large part of why we keep CanvasWP‘s shop templates deliberately boring under the hood.
How to speed up WooCommerce at catalogue scale
Everything above applies to a 50-product store. These are the problems that only bite past a few thousand SKUs.
Variable products. Below woocommerceajaxvariationthreshold (default 30), Woo serialises every variation’s full data into a data-productvariations attribute in the HTML. We have measured 340KB of inline JSON on a product with 60 variations. Lower the threshold so it fetches on demand:
addfilter( 'woocommerceajaxvariationthreshold', function () {
return 12; // above this, variations load via AJAX on selection
} );
The trade-off: each attribute selection now costs a round trip, around 200 to 400ms. For a two-attribute product that’s acceptable. For a configurator with five attributes it’s miserable, and the right answer is to restructure the catalogue rather than tune the threshold.
Related products. WooCommerce’s related products query uses ORDER BY RAND(), which forces a full sort of the candidate set. It’s wrapped in a transient, so it only hurts on cache misses, but on a large catalogue with frequent invalidation that can be often enough. If the section drives no revenue, remove it. If it does, replace it with a curated upsell list.
Layered nav and filters. Attribute filtering on a big catalogue goes through wcproductmeta_lookup and the attribute lookup table. Make sure the lookup table is actually enabled and regenerated (WooCommerce, Status, Tools). Filters that fall back to postmeta joins across 12,000 products are where those 1,800-query page loads come from.
HPOS. High-Performance Order Storage, default for new installs since WooCommerce 8.2, is a genuine win for admin order lists and order queries at scale. It will do nothing for your product page speed. Migrate for the admin experience and the reporting, not because a listicle told you it’s a front-end optimisation.
And do all of this on a staging copy with a real database dump, not on production at 10am. Our approach to that is in Staging, Version Control and Deploys for WordPress Teams.
Frequently Asked Questions
Will a caching plugin alone fix my slow product pages?
No, and it can hide the problem in a way that costs you money. Page caching makes the second visitor fast while the first visitor, Googlebot, and everyone with items in their cart still pay the full uncached cost. Get uncached TTFB under 600ms first, then add page caching as a multiplier on top of work that’s already correct.
How many database queries should a WooCommerce product page run?
Between 60 and 120 is normal for a product page with a gallery, variations and a few related items. Over 300 means something is querying in a loop, usually a plugin calling wcgetproduct() inside a template iteration. Query Monitor’s Queries by Component panel will name the culprit in under a minute.
Is it safe to delete rows from wpwoocommercesessions?
Deleting expired sessions is safe: those carts are already gone as far as Woo is concerned. Truncating the whole table empties every live guest cart, which you should only do outside trading hours and after a backup. The maintainable fix is running woocommercecleanupsessions on a real cron schedule so the table never grows past a few thousand rows.
Does switching to a block theme improve WooCommerce speed?
Not reliably. Block themes replace PHP templates with block markup, which shifts work rather than removing it, and wc-blocks-style.css plus the Interactivity API runtime add front-end weight that classic templates don’t carry. Judge a theme by its rendered query count and asset payload on a product page, not by its architecture label.
Should I move off WooCommerce if performance is this much work?
Only if your bottleneck is genuinely the platform rather than its configuration, which in our experience is rare below roughly 100,000 SKUs or 500 concurrent checkouts. A tuned WooCommerce store on decent hosting with Redis and a correct cache bypass serves product pages in well under 500ms uncached. Replatforming costs six figures of effort and reintroduces every integration bug you already fixed.
Where to start on Monday
Run the curl loop against three product URLs with the cache bypassed and write the numbers down. If TTFB is above 600ms, open Query Monitor, check the autoload total and the Action Scheduler row count, and confirm a persistent object cache is actually connected. Those four checks take twenty minutes and resolve the majority of WooCommerce performance complaints we get handed.
If TTFB is already fine and the page still feels slow, it’s an LCP problem. Find the main product image, make sure it’s eager with fetchpriority="high", and strip the gallery scripts you don’t use. Pick whichever half your numbers point to. Don’t do both at once, or you’ll never know which change earned the improvement.


