Where the time goes in a slow WooCommerce store

A profiling-first tour of the usual suspects in WooCommerce performance: the post meta model, cart fragments, uncached sessions, the checkout, and the plugins that touch every request. With the fixes that work and the ones that only look like they do.

Most WooCommerce performance work starts with the wrong question: “which caching plugin should we install?” Caching hides cost; it does not remove it, and the pages that matter in a store (cart, checkout, account) are the ones that cannot be cached anyway.

The right first question is “where does the time go?” This article walks through the places we look first when a store is slow, roughly in the order they tend to matter, and what we do about each. The method is always the same: measure, change one thing, measure again.

Measure before touching anything

Three tools cover almost everything:

  • Query Monitor on a staging copy with production data, to see the queries, hooks and HTTP calls behind a single request.
  • Server timing or an APM (New Relic, Blackfire, or Server-Timing headers from the host) to see the distribution across many requests, including the ones you would not think to load yourself.
  • A slow query log on the database, with a low threshold, for a day.

From these you get a short list: the slowest routes, the slowest queries, the plugins that appear in every trace. Fix in that order. Everything below is the catalogue of what usually turns up.

The product data model

WooCommerce stores products as posts and almost everything about them as post meta. Price, stock, SKU, attributes, visibility: each is a row in wp_postmeta, and a catalogue of ten thousand products with fifty meta keys each is half a million rows in a table indexed on meta_key but not usefully on meta_value.

Symptoms:

  • Shop and category pages that get slower as the catalogue grows.
  • Filters and sorting by price or attribute that take seconds.
  • Admin product lists that time out.

What works:

  • Use the lookup tables. WooCommerce maintains wp_wc_product_meta_lookup precisely so that price, stock and rating queries do not hit post meta. Custom queries that sort or filter by price should join that table, not wp_postmeta. Check that the table is populated (wc update and the regeneration tools exist for a reason).
  • Stop querying meta for lists. A product grid that calls get_post_meta() per product for a value the lookup table already has is the most common self-inflicted wound we see in themes.
  • Index what you actually filter on. If the business genuinely filters on a custom meta key, that data belongs in a custom table with an index, or at minimum in a taxonomy.

Orders: move to HPOS

Orders have the same problem as products, only worse, because order data grows without limit and is written on every checkout. High-Performance Order Storage (HPOS) moves orders into dedicated tables with real columns and indexes. On stores with more than a few tens of thousands of orders it is the single largest database win available, and it is now the default for new installations.

The work is in compatibility. Every plugin that touches orders must declare HPOS support and use the CRUD API ($order->get_meta(), wc_get_orders()) instead of get_post_meta() and WP_Query on the shop_order post type. The migration is safe and reversible, but it is a project, not a checkbox: audit the plugins, run with compatibility mode on staging, then switch.

Cart fragments and the uncacheable request

Every page in a default WooCommerce theme fires an AJAX request after load to refresh the mini-cart (“cart fragments”). It is uncached by definition, it starts WordPress from scratch, and it runs even for visitors who have no cart. On a busy store it can be the majority of PHP requests.

Options, in order of preference:

  1. Do not load the fragments script on pages that do not need it. Product and content pages rarely need a live mini-cart. Dequeue wc-cart-fragments outside the cart and checkout, and render the cart count from the session on the rare pages that show it.
  2. Use the Store API and the Blocks mini-cart, which fetch cart state only when opened.
  3. If the design insists on a live count everywhere, at least make that one endpoint as cheap as possible by unloading unrelated plugins on AJAX requests.

The same reasoning applies to every plugin that adds an uncached request to each page view: live search, wishlist counters, recently viewed products, analytics trackers routed through WordPress. Each one is a full WordPress boot.

Sessions and the object cache

WooCommerce keeps a session per visitor with a cart, in wp_woocommerce_sessions, and reads it early on nearly every request. Without a persistent object cache, transients, options and sessions all round-trip to MySQL.

A persistent object cache (Redis or Memcached) is the first infrastructure change we make on any store that lacks one. It turns thousands of repeated small queries into memory lookups. It also has two consequences that need managing:

  • Autoloaded options. Everything with autoload = yes in wp_options is loaded on every request. Plugins that store large serialized arrays as autoloaded options make every request slower, cache or not. We audit the autoloaded size (anything over about a megabyte is worth cleaning) and fix the offenders.
  • Transient hygiene. WooCommerce and many extensions cache computed results in transients. With an object cache these live in memory and expire cleanly; without one they accumulate in the database until something cleans them up.

Checkout

Checkout is the request that matters most and is the hardest to speed up, because it runs the most code: shipping calculation, tax, payment gateway initialisation, validation, plus every plugin that hooks into the process.

Where the time usually goes:

  • Shipping methods that call external APIs on every recalculation. Rates should be cached per destination and cart signature for a sensible interval.
  • Tax lookups done live instead of from a cached table.
  • Gateways that initialise on every page, not just the checkout. Payment plugins are frequent offenders here, loading SDKs and making token calls on the home page.
  • Order creation hooks that send emails synchronously, call a CRM, or regenerate reports before the customer sees the confirmation page. Those belong in a queue (Action Scheduler is already there).

Blocks-based checkout with the Store API is measurably lighter than the shortcode checkout, because the front end talks to a purpose-built REST API instead of posting the entire form and re-rendering. Whether a store can use it depends on gateway and extension support, which is the audit we do first.

Plugins that touch every request

The last category is the most political. Every active plugin has a fixed cost on every request: files loaded, hooks registered, options read. Most are cheap. A few are not, and they do not announce themselves.

The trace tells you which ones. In our experience the usual names are page builders with front-end assets, all-in-one SEO and security suites, translation plugins that scan output, and analytics or marketing integrations that load their SDK server-side. The fix is rarely “delete it”; it is usually “load it only where it does something”, which is a small must-use plugin that deactivates plugins by route.

What does not help much

Two things show up in every performance conversation and rarely move the numbers on a store:

  • Front-end optimisation plugins (minify, combine, defer). They improve a Lighthouse score on a cached product page and do nothing for the checkout, which is where revenue is lost. Do them last, not first.
  • Moving hosts without changing anything else. A faster server makes a slow query faster in absolute terms and changes nothing about how the store scales. If the plan is to grow, the data model and the request profile have to change.

The order we work in

  1. Measure: traces, slow query log, autoload size, request counts per route.
  2. Infrastructure that removes whole classes of cost: object cache, PHP 8.x, HTTP/2, a CDN for static assets.
  3. Data model: lookup tables, HPOS, custom tables for anything filtered at scale.
  4. Request profile: remove uncached requests per page view, load plugins by route.
  5. Checkout: cache external calls, queue side effects, evaluate Blocks checkout.
  6. Front-end polish, once the server side is honest.

Every step is reported with a before and an after, on the routes that matter. That is the difference between performance engineering and performance plugins.

Next step

Working on something similar?

Tell us about the site, the stack and what is not working. We reply with questions, not a sales deck.