Default WooCommerce stalls before the catalogue gets interesting. A clean install with 200 SKUs feels fast in staging, then the same template under 30k products and 50k orders hits TTFB in the 1.5 to 3 second range, cart fragments serialise on the shop archive, and the wp_options autoload row count quietly crosses 100k.
The platform itself is fine. WooCommerce powers stores doing eight figures a year. The configuration is what kills it.
This is a field guide for hardening WooCommerce against real load. It assumes you already have shell access, WP-CLI, and a willingness to read slow query logs. For mobile checkout lag without a rebuild, start with the performance recovery case study and the checkout latency diagnostic CSV; the layered playbook lives in WooCommerce performance optimization 2026.
WooCommerce HPOS migration and what it changes
For a decade WooCommerce stored orders as shop_order posts in wp_posts, with every line item, address, and metadata field exploded into rows in wp_postmeta. A “show me orders from May” query meant scanning a table mixed with blog revisions, autosaves, and draft pages, then JOINing postmeta three or four times to reconstruct each order.
High Performance Order Storage moves orders into wc_orders, wc_order_addresses, wc_order_operational_data, and wc_orders_meta. Order list queries now hit indexed columns directly rather than reconstructing each row from postmeta.
HPOS has been the default for new installations since WooCommerce 8.2, released in October 2023, according to the HPOS documentation. Stores that launched before that release often still run on posts storage, which is why the migration below is still the first item on most audits.
What HPOS does not fix on its own:
- Custom plugins that still write to postmeta keys instead of the new HPOS API will silently desync once compatibility mode is off
- Third party plugins that read order data with
get_post_meta($order_id, ...)instead of$order->get_meta()will return nothing once the legacy table stops being authoritative - Reporting plugins built on
WP_Queryagainstshop_orderneed a rewrite
Workflow:
- WooCommerce -> Settings -> Advanced -> Features -> turn on High Performance Order Storage with compatibility mode on
- Let the sync run during low-traffic hours; on a store with 200k orders this can run for several hours
- Verify with
wp wc hpos verify_data - Run your full plugin suite against the new tables for a week with compatibility mode still writing to postmeta
- Disable compatibility mode only after you have confirmed every order-touching plugin reads from HPOS
wp wc hpos status
wp wc hpos sync --batch-size=500
wp wc hpos verify_dataHPOS WP-CLI commands after WooCommerce 8.9
Older tutorials use the wp wc cot namespace. WooCommerce 8.9.0 deprecated it: the old commands still run, but each one prints a warning that names its wp wc hpos replacement, and verify_cot_data became verify_data (CLIRunner.php). The HPOS CLI documentation and the command docblocks list the current set:
wp wc hpos statusshows the HPOS settings and the sync statewp wc hpos count_unmigratedcounts orders still waiting for syncwp wc hpos syncmigrates orders in batches, and--batch-sizedefaults to 500wp wc hpos verify_datacompares both datastores; its--re-migrateflag re-syncs orders that differ and can overwrite an order, so try it on staging firstwp wc hpos diff 123shows the differences for a single orderwp wc hpos compatibility-mode enableanddisableswitch synchronisation without opening the admin screenwp wc hpos cleanupremoves legacy post data for migrated orders. The documentation calls it destructive, and without arguments it does nothing;wp wc hpos cleanup allprocesses every order
Run cleanup only after compatibility mode has been off long enough that no plugin reads the legacy copy, and only after a database backup.
How to make a custom plugin HPOS compatible
WooCommerce warns the store owner when they try to enable HPOS while a plugin that declared itself incompatible is active, and shows the same warning on the Plugins screen. For plugins that are not WooCommerce extensions, the warning appears only when the plugin header carries a WC tested up to line. A custom plugin declares its status in its main file, as shown in the HPOS recipe book:
add_action( 'before_woocommerce_init', function() {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
} );The declaration is a promise, not a fix. The code behind it has to load orders with wc_get_order() instead of get_post(), and write metadata through the order object (update_meta_data(), add_meta_data(), delete_meta_data(), then save()) instead of update_post_meta(). WooCommerce then decides which tables receive the write. The recipe book notes that save() is relatively expensive, so a loop that saves after every single meta change is a performance bug of its own. Code that must branch on the storage engine can call OrderUtil::custom_orders_table_usage_is_enabled().
How to disable cart fragments in WooCommerce
Run an older WooCommerce store, or one with a cart widget in the header, through GTmetrix or WebPageTest and you will see ?wc-ajax=get_refreshed_fragments showing up on every page, often eating 800 to 1500 ms. This is WooCommerce asking PHP “did the cart icon in the header change?” on the homepage, on the blog, on category archives, on the about page. Even when the cart is empty. Even when the user has never added anything.
A real failure mode: a Stripe-checkout fashion store hit Black Friday with a paid traffic spike around 200 concurrent sessions. The checkout flow itself was fine, but cart fragments serialised through admin-ajax.php on the shop archive page where most of the paid traffic landed. PHP-FPM workers queued, TTFB on the archive crossed 8 seconds, and conversions collapsed for ninety minutes until the engineering team scoped fragments to commerce pages with a hard dequeue.
The simplest scoping rule:
add_action( 'wp_enqueue_scripts', function() {
if ( is_cart() || is_checkout() || is_product() ) {
return;
}
wp_dequeue_script( 'wc-cart-fragments' );
}, 11 );Plugins like Perfmatters and Asset CleanUp expose this as a UI toggle. Either path works; the goal is the same. Cart fragments should fire only where a cart icon needs live updating.
If you genuinely need a header cart count on every page, store it in localStorage and update from JavaScript on add-to-cart events rather than polling the server.
When WooCommerce still loads cart fragments after version 7.8
WooCommerce 7.8 changed the default. According to the WooCommerce developer blog post Best practices for the use of the “cart fragments” API (16 June 2023), the script is no longer enqueued on every route of the store. It still loads in three cases: on pages where the Cart widget is rendered, when a third-party script registers the cart fragments script as a dependency, and when code enqueues it explicitly. The same post notes that some themes, Storefront among them, hard-code the widget into their templates.
On a current store the request in the waterfall is therefore a symptom with a findable cause. Search the page source for the wc-cart-fragments handle, then trace which theme template or plugin pulls it in. Dequeueing it without that check can break a header cart that the theme still refreshes through fragments, and a plugin that lists the script as a dependency will enqueue it again anyway.
Object cache versus database transients
WooCommerce uses transients heavily for variation price ranges, attribute term lookups, and shipping rate calculations. Without an object cache backend, transients live in wp_options. Whether they load on every request depends on the expiry: set_transient() stores a transient without an expiry as autoloaded, and a transient with an expiry as not autoloaded, plus a separate _transient_timeout_ row. WooCommerce sets its variation price and variation children transients with a 30-day expiry (variable product data store), so they mostly inflate the row count of wp_options. Autoload weight comes from options that plugins write without an expiry.
Two failure modes worth recognising:
- A bookstore with 12k variable products had
wc_var_prices_*transients accumulating inwp_optionsfor two years. The table crossed 80 MB. Every uncached page load did a SELECT on awp_optionstable with 130k rows just to bootstrap WordPress. TTFB sat at 2.4 seconds before any business logic ran - A B2B catalogue store ran 30+ plugins, baseline TTFB 1.8 s. After identifying four plugins that wrote autoloaded options on every product save and disabling them, TTFB dropped to around 600 ms with no other change
Pick one cache backend and commit. Either Redis or Memcached as a real object cache, or stay on database transients. Mixing them causes invalidation bugs that are hard to diagnose.
# Audit autoload size in wp_options
wp db query "SELECT SUM(LENGTH(option_value)) AS bytes, COUNT(*) AS rows FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto');"
# Find the worst offenders
wp db query "SELECT option_name, LENGTH(option_value) AS bytes FROM wp_options WHERE autoload IN ('yes', 'on', 'auto-on', 'auto') ORDER BY bytes DESC LIMIT 20;"With Redis configured at the host level and object-cache.php dropped in, set_transient() writes to the object cache instead of wp_options, transients move to RAM, autoload pressure drops, and product page render time on a cold cache typically goes from the 500 to 800 ms range to under 100 ms.
Autoload values in WordPress 6.6 and the 800 KB warning
WordPress 6.6 changed how autoload is stored. The core developer note Options API: disabling autoload for large options introduces the database values on, off, auto, auto-on and auto-off; on and off are explicit choices, while auto-on and auto-off are set by WordPress itself. The function wp_autoload_values_to_autoload() returns yes, on, auto-on and auto, which is why the audit queries above filter on that list. A query that still checks only autoload = 'yes' misses every option saved after the upgrade and reports a store as cleaner than it is.
Two thresholds came with the change. An option larger than 150 000 bytes is not autoloaded unless the code requests it explicitly, and the wp_max_autoloaded_option_size filter moves that limit. Site Health flags the site when the total size of autoloaded options exceeds 800 KB. That Site Health check is the cheapest first test on an unfamiliar store, because it needs no shell access and names the problem before anyone opens a database client.
Page caching exceptions and what must never be cached
Static caching helps archives, single product pages, and blog posts. It actively breaks anything that depends on a per-user session.
Hard exclusions for any page cache or Cloudflare Page Rule:
/cart//checkout//my-account/- Any URL containing
?wc-ajax= - Any URL containing the
woocommerce_items_in_cartcookie - The REST endpoints under
/wp-json/wc/
Cloudflare APO can cache HTML for logged-out users and respects WooCommerce session cookies if configured correctly, but verify with curl -I on cart and checkout that you are getting cf-cache-status: BYPASS. A misconfigured cache that serves another shopper’s cart is a GDPR incident, not a performance win.
Variation product queries and the woocommerce_variable_children_args filter
Variable products with 50+ variations each are a JOIN multiplier. The product archive on a clothing store with 200 variable products and 30 variations each is doing a JOIN against wp_posts for 6000 child products on every load.
Two practical fixes:
- Limit the variations loaded for the dropdown to those that are in stock and visible
- Use
woocommerce_variable_children_argsto exclude private and out-of-stock variations from the initial load on archive pages
add_filter( 'woocommerce_variable_children_args', function( $args ) {
if ( is_shop() || is_product_category() ) {
$args['post_status'] = 'publish';
$args['meta_query'][] = array(
'key' => '_stock_status',
'value' => 'instock',
);
}
return $args;
} );Action Scheduler queue table bloat
Every WooCommerce extension that does background work uses Action Scheduler. Subscriptions, follow-up emails, abandoned cart recovery, stock sync, ERP integrations, all of it ends up in wp_actionscheduler_actions and wp_actionscheduler_logs.
On a site with active subscriptions and abandoned cart recovery, these tables accumulate millions of completed and failed actions within a year. The tables get queried on every admin page load to render the queue counter.
Maintenance script:
# Delete completed and canceled actions older than 31 days
wp action-scheduler clean --before="31 days ago" --pause=1
# Inspect queue size
wp db query "SELECT status, COUNT(*) FROM wp_actionscheduler_actions GROUP BY status;"
# Verify indexes exist on status and scheduled_date_gmt
wp db query "SHOW INDEX FROM wp_actionscheduler_actions;"How long Action Scheduler keeps completed and failed actions
Action Scheduler cleans up after itself during queue runs. Its queue cleaner deletes completed and canceled actions once they are older than 31 days, and the action_scheduler_retention_period filter changes that window. Since Action Scheduler 4.0 failed actions are deleted as well, after three months by default (action_scheduler_retention_period_for_failed); the code comment ties that period to the quarterly accounting cycle.
The cleanup runs inside the queue runner. On a store that disables WP-Cron and has no system cron calling wp-cron.php or wp action-scheduler run, the queue stops and the cleanup stops with it, without any error on screen. A backlog left by such a gap can be cleared by hand with wp action-scheduler clean, which per the Action Scheduler WP-CLI reference takes --status (default canceled,complete), --before (default 31 days), --batch-size (default 20) and --pause to wait between batches on a busy database.
How to optimise WooCommerce product images
Product images are usually the heaviest payload. Three rules:
- AVIF for hero and gallery images, with a WebP fallback for older Safari versions still in your traffic mix
- Stop letting clients upload 4000px raw exports. Cap the upload pipeline at 2000px on the long edge
- Native
loading="lazy"is enough; remove any old JavaScript lazy loaders that fight it
Pre-resize before upload rather than relying on WordPress to generate thumbnails on the fly, which writes to wp_postmeta and inflates the database.
WooCommerce database cleanup with WP-CLI
Run these monthly, more often on stores with high cart abandonment volume:
# Delete expired sessions (only needed when the scheduled cleanup has stopped)
wp db query "DELETE FROM wp_woocommerce_sessions WHERE session_expiry < UNIX_TIMESTAMP();"
# Clear all WooCommerce transients
wp wc tool run clear_transients --user=1
# Optimize tables
wp db optimize
# Check Action Scheduler queue depth
wp action-scheduler statusSchedule via system cron rather than wp-cron.php, which only fires on traffic. The --user flag on wp wc tool run follows the WooCommerce CLI examples; use the ID of an administrator account on your install.
How WooCommerce cleans up customer sessions
WooCommerce already prunes its session table. It registers a recurring Action Scheduler action, woocommerce_cleanup_sessions, that runs every 12 hours (class-woocommerce.php), and the session handler deletes expired rows in batches of 100 with a short pause between batches. A guest session expires after two days and a logged-in customer’s after seven; the wc_session_expiration and wc_session_expiring filters change both. Lifetimes beyond 30 days write a warning to the WooCommerce log, because longer sessions grow the table.
Two consequences follow. A session table measured in gigabytes usually means the Action Scheduler queue is not running or a plugin raised the session lifetime, so check wp action-scheduler status and those filters before deleting anything. And never run TRUNCATE on wp_woocommerce_sessions on a live store: it empties every active cart, while the DELETE ... WHERE session_expiry < UNIX_TIMESTAMP() above removes only rows that have already expired.
Which WooCommerce performance metrics to track
Optimise blind and you will optimise the wrong thing. Pick three signals and track them weekly:
- TTFB on a cold cache for the shop archive and a single product page
- Time to first byte on
/cart/and/checkout/with one item and four items in the cart - Wp_options autoload row count and total bytes
- Action Scheduler queue depth and oldest pending action
Query Monitor in staging tells you which queries dominate. New Relic or Datadog APM in production shows the real distribution. PageSpeed Insights is a smoke test, not a diagnosis.
Last updated
2026-10-14. Field notes from production WooCommerce audits over the past year. If your store does not match these patterns, the patterns are not the issue. Read your slow query log first.







