Skip to content
  • FG-56
  • 13 min read
  • Last checked November 8, 2026

How to Fix a Slow WooCommerce Store: Cart, Checkout, Fragments, Variations and Filters

Product pages load fine but cart, checkout and filtered shop pages crawl. Measure the uncached path, then fix fragments, geolocation, variations, lookup tables and object cache.

Run the health check

Your WooCommerce store feels quick when you click around as a visitor, but customers complain. Adding to cart takes seconds, the cart page hangs, checkout redraws slowly every time a field changes, and filtering the shop by size or color crawls. PageSpeed scores for the home page look fine, which makes it worse: the slow part is the part that takes money.

The reason is structural. A content site can serve almost every visitor from a page cache. A store cannot: the cart, checkout and account pages must be built fresh for each shopper, and once a shopper has something in the cart, many cache setups stop caching any page for them at all. So a store’s real speed is its uncached speed, plus the extra background requests WooCommerce makes. This guide covers the WooCommerce-specific causes. General server response time, slow admin screens and database bloat have their own guides (FG-32, FG-33 and FG-34), and this one does not repeat them.

What is happening

Request Cacheable? What makes it slow
Shop, category and product pages for anonymous visitors Yes Rarely slow if the page cache works
Any page for a shopper with a cart (cookie bypass) Usually not, unless the cache varies by cart Full WordPress and WooCommerce boot on every click
Cart, checkout, my-account Never Totals, tax, shipping rate lookups, coupon checks, gateway scripts
?wc-ajax=get_refreshed_fragments No (POST) Boots WordPress just to rebuild the mini cart
?wc-ajax=update_order_review No (POST) Recalculates the whole order when checkout fields change
Filtered shop URLs (?filter_color=, ?min_price=) Often skipped because of the query string Taxonomy and meta queries across products and variations

Store speed work therefore has two goals: make the uncached path faster, and cut the number of uncached requests each shopper makes.

Before you start

  • Access: WordPress admin, SSH for database checks, and your cache plugin and CDN settings.
  • Backup: measuring changes nothing. Back up the database before regenerating lookup tables or clearing transients on a large catalog, and run heavy tools in a quiet hour.
  • Running commands on the server: SSH in and cd to the folder containing wp-config.php. WP-CLI is wp on most hosts and wp-cli on some. Database commands below use the wp_ table prefix; run wp db prefix and substitute yours.
  • Running commands on your computer: curl is built into macOS, Linux and current Windows (type curl.exe in PowerShell, and use NUL instead of /dev/null).
  • Query Monitor: install it for Step 6. It shows queries, hooks and HTTP calls for the page you are viewing, and the AJAX requests it triggered.

Step 1: Measure the shopper path, not the visitor path

Time a product page as an anonymous visitor, then as a shopper with a cart, then the cart page itself with a real cart. Run these on your computer:

curl -o /dev/null -s -w "visitor ttfb %{time_starttransfer}s status %{http_code}\n" "https://example.com/product/PRODUCT_SLUG/"
curl -o /dev/null -s -w "shopper ttfb %{time_starttransfer}s status %{http_code}\n" -H "Cookie: woocommerce_items_in_cart=1; woocommerce_cart_hash=TESTHASH" "https://example.com/product/PRODUCT_SLUG/"
curl -s -c jar.txt -b jar.txt -o /dev/null "https://example.com/?add-to-cart=PRODUCT_ID"
curl -o /dev/null -s -b jar.txt -w "cart ttfb %{time_starttransfer}s status %{http_code}\n" "https://example.com/cart/"

Placeholders: example.com is your domain; PRODUCT_SLUG is the end of a product’s URL; PRODUCT_ID is the ID of a simple, in-stock product (hover over it in Products > All Products); TESTHASH is any made-up text, because most caches only check that the cookie exists. Change /cart/ if your cart page has another address. Flags: -o /dev/null discards the HTML, -s hides progress, -w prints time to first byte and the status, -H "Cookie: ..." imitates a shopper, and -c/-b jar.txt keep the real session cookies between requests. Run each line three times and take the middle value.

How to read it: the visitor line should be fast if your page cache works (if not, start with FG-32). The shopper line shows what customers feel on every page after adding to cart. If it is several times slower than the visitor line, your cache bypasses everything for shoppers, so the uncached path is what you must fix. The cart line includes totals and shipping; if it is much slower than the shopper product page, look at shipping and tax plugins first (Step 6). Delete jar.txt afterward.

Some cache setups, such as the LiteSpeed Cache plugin’s WooCommerce integration, keep separate cached copies per cart instead of bypassing. In that case the shopper line can stay fast, which is the better design where available.

Step 2: Cut the background requests: cart fragments

Classic themes keep the header cart count correct on cached pages with the wc-cart-fragments script. It stores the mini cart HTML in the browser’s sessionStorage and calls /?wc-ajax=get_refreshed_fragments whenever that stored copy is missing, older than a day, or does not match the woocommerce_cart_hash cookie. Each call is an uncached POST that loads all of WordPress, WooCommerce and your plugins.

WooCommerce changed the default in version 7.8: it no longer loads this script on every page, only where the legacy Cart widget is used. The change is listed in the WooCommerce changelog as “Removed global enqueue of wc-cart-fragments. Moved to the cart widget (its main consumer).” Many themes and plugins still load it everywhere, so check yours.

Open a product page in a private window, open DevTools > Network, reload, and filter for wc-ajax. If get_refreshed_fragments appears on every page view, time it from your computer:

curl -o /dev/null -s -w "fragments %{time_total}s status %{http_code}\n" -X POST "https://example.com/?wc-ajax=get_refreshed_fragments"

Placeholder: your domain. Flags: -X POST sends the same method the script uses, and time_total is the full request time. Good output: status 200 and a time similar to your uncached TTFB. Bad output: several seconds, which every visitor pays on arrival.

Your options, best first:

  • Use the block-based Mini-Cart where your theme allows; it reads the cart from the Store API and does not use fragments.
  • Use the theme’s setting to disable fragments or the header cart, if it has one.
  • Stop the script loading where it is not needed. In a child theme’s functions.php or a small must-use plugin (a file you edit):
add_action( 'wp_enqueue_scripts', function () {
    if ( ! is_cart() && ! is_checkout() ) {
        wp_dequeue_script( 'wc-cart-fragments' );
    }
}, 99 );

What it does: after themes and plugins have queued their scripts (priority 99 runs late), it removes the fragments script everywhere except the cart and checkout. No placeholders. Trade-off: on cached pages the header count will show whatever was cached until the shopper reaches the cart. Only use it if your header shows no live count, or you accept that. Good result: no get_refreshed_fragments request on product pages in the Network tab.

Step 3: Remove geolocation redirects

Open WooCommerce > Settings > General and look at Default customer location. The options are No location by default, Shop country/region, Geolocate and Geolocate (with page caching support).

The last one works by redirecting the first request for every page with a 307 to the same URL plus ?v= and a location hash, so caches store one copy per location. That costs an extra round trip, and any cache rule that skips URLs with query strings (common, and used in the Nginx rule in FG-54) then treats every page as uncacheable. Plain Geolocate looks up each visitor’s country on the server, which a shared page cache cannot represent correctly.

curl -sI "https://example.com/shop/" | grep -iE "^HTTP|^location"

Placeholder: your shop URL. Flags: -I fetches headers only; grep keeps the status and redirect target. Good output: HTTP/2 200. Bad output: 307 with a location ending in ?v= and a hash. If you do not need per-country prices or taxes before checkout, choose Shop country/region; the customer’s real address still drives tax at checkout. If you need geolocation for compliance, let your CDN pass the visitor country and use a cache that varies by it instead.

Step 4: Fix variable products and attribute filters

Large variable products

On a variable product page, WooCommerce embeds all variation data in the page only if the product has 30 variations or fewer (the woocommerce_ajax_variation_threshold filter). Above that, each attribute choice triggers a ?wc-ajax=get_variation request. Price ranges are cached in transients such as wc_var_prices_ plus the product ID, which are rebuilt whenever a variation changes. Find your heaviest products:

wp db query "SELECT post_parent AS product_id, COUNT(*) AS variations FROM wp_posts WHERE post_type = 'product_variation' GROUP BY post_parent ORDER BY variations DESC LIMIT 10;"

Where: server SSH. Placeholder: replace wp_ with your table prefix. Good output: a short table of product IDs and variation counts. What to look for: products with hundreds of variations. Those pages build slowly, save slowly and rebuild price transients often. Splitting them into separate products, or using attributes that do not need to be variations (for example a text option handled by a product add-ons extension), usually helps more than any server change.

Lookup tables

WooCommerce keeps wp_wc_product_meta_lookup, a flat table of price, stock, SKU, sales and rating per product, so sorting and price filtering avoid slow meta queries. It also has an optional product attributes lookup table for filtering by attribute. If imports or direct database edits bypassed WooCommerce, these tables drift out of date, which makes filters slow and results wrong.

wp db query "SELECT (SELECT COUNT(*) FROM wp_posts WHERE post_type IN ('product','product_variation')) AS products, (SELECT COUNT(*) FROM wp_wc_product_meta_lookup) AS lookup_rows;"
wp wc tool run regenerate_product_lookup_tables --user=ADMIN_USERNAME

Placeholders: your table prefix in place of wp_; ADMIN_USERNAME is an administrator login, needed because the tool runs through WooCommerce’s REST API. What each does: the query compares the number of products and variations (all statuses) with rows in the lookup table; the tool queues a background rebuild, the same as Product lookup tables > Regenerate in WooCommerce > Status > Tools. Good output: counts in the same range (auto-drafts and trashed items can make them differ a little), and a success message from the tool. Bad output: far fewer lookup rows than products, which means the table needs the rebuild.

For attribute filtering, go to WooCommerce > Settings > Products > Advanced. Under Product attributes lookup table, check Enable table usage (“Use the product attributes lookup table for catalog filtering”). If the table was never built, run Regenerate the product attributes lookup table in Status > Tools first. Heavy filter and search plugins usually maintain their own index tables; make sure their indexing has completed and runs after imports, and prefer ones that use an index over ones that run live queries for every filter count.

Step 5: Use an object cache, and keep store tables in check

WooCommerce leans on the WordPress object cache for sessions, product data, query results and versioned transients. Without a persistent object cache (Redis or Memcached), all of that is rebuilt or read from wp_options on every uncached request, and transients pile up in the database. For a store, a persistent object cache is the single biggest lever on the uncached path. Site Health includes a persistent object cache check with WooCommerce-specific criteria, so a store that would benefit will usually see a recommendation there. Give each site (live and staging) its own Redis database or key prefix, for the reasons in FG-54.

Then check which store tables are large:

wp db query "SELECT table_name, ROUND((data_length + index_length) / 1048576, 1) AS size_mb, table_rows FROM information_schema.tables WHERE table_schema = DATABASE() AND (table_name LIKE '%wc\_%' OR table_name LIKE '%woocommerce%' OR table_name LIKE '%actionscheduler%') ORDER BY size_mb DESC LIMIT 15;"

Where: server SSH. No placeholders; DATABASE() means the current site’s database. Good output: sizes that make sense for your catalog and order volume. What to look for:

  • woocommerce_sessions very large: sessions are not being cleaned (the woocommerce_cleanup_sessions job is not running) or a bot is creating carts. Fix cron and bot traffic (FG-36) rather than emptying the table, which deletes live carts.
  • actionscheduler_actions and actionscheduler_logs very large: completed actions are not being purged, or something is failing in a loop. Cleaning and diagnosing the queue is covered in FG-33.
  • Order tables: with HPOS, orders live in wc_orders and related tables, separate from products in wp_posts, which keeps product queries lean. If HPOS compatibility mode is on, every order write is done twice to keep the posts copy in sync. Once no active plugin needs the legacy tables (wp wc hpos compatibility-info tells you), turning compatibility mode off removes that overhead. Migrating and verifying HPOS is covered in FG-55.

Expired transients and autoloaded options are general database work; see FG-34. WooCommerce > Status > Tools has WooCommerce transients and Expired transients clear buttons if you need them.

Step 6: Profile the cart and checkout themselves

With Query Monitor active, open the cart and checkout as a logged-in test customer with items in the cart. Check three panels:

  • HTTP API Calls: live shipping rates, tax services, address validation and fraud checks call external APIs during cart and checkout. A two second carrier API call on every update_order_review makes checkout feel broken. Look for caching options in those plugins, or use flat or table rates where live rates add little.
  • Queries by Component: shows which plugin runs the most or slowest queries on these pages. Cart upsell, dynamic pricing and discount rule plugins are frequent culprits because they evaluate every rule for every cart item on every recalculation.
  • Hooks & Actions: shows which plugins attach to cart calculation hooks such as woocommerce_before_calculate_totals, which run on every recalculation.

On the checkout, every change to the country, ZIP code or shipping method fires update_order_review. Watch the Network tab while you fill in the form: if each request takes more than a second, the cost is inside the totals calculation, not the page.

Advanced: Size PHP workers for uncached traffic

Because store traffic is uncached, it is the number of PHP workers, not the page cache, that decides how many shoppers you can serve at once during a sale. A useful estimate: requests per second you can sustain is roughly the number of PHP workers divided by the average uncached request time in seconds. With 8 workers and checkout requests taking 0.8 seconds, you can process about 10 uncached requests per second before requests queue. Each shopper generates several uncached requests per page (the page itself plus wc-ajax calls), so cutting fragments and geolocation redirects raises capacity as much as adding workers does.

To see the real per-request cost on the server, read the access log during busy periods. Many hosting log formats include the request duration as the last field:

grep -E "wc-ajax=(get_refreshed_fragments|update_order_review|checkout)" /PATH/TO/access.log | tail -n 30

Where: server SSH. Placeholder: /PATH/TO/access.log is your site’s access log; ask your host if you cannot find it. Flags: -E enables the alternation pattern, matching any of the three endpoints, and tail -n 30 shows the most recent 30. Good output: mostly 200 responses with short durations. Bad output: many 502, 503 or 504 responses at peak times, which means workers ran out (see FG-14), or fragment requests far outnumbering page views, which means Step 2 will help most. Tuning PHP-FPM worker counts themselves is covered in FG-13 and FG-14.

When to contact your host

Contact your host when the uncached path is slow even with plugins disabled on staging, when you need a persistent object cache enabled, when 503 or 508 errors appear during sales, or when you need more PHP workers or memory. Send:

  • The curl timings from Step 1 for visitor, shopper and cart, with UTC times.
  • The number of wc-ajax requests per page view and their timings.
  • Your largest tables from Step 5 and your catalog size (products and variations).
  • Peak traffic times and any error codes seen in the access log.

The diagnostic tools page helps you capture timings and headers. If the store is timing out for customers right now, start at the Emergency Room.

Still stuck?

Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.

Get the free checklists

Keep reading

All field guides

Search the field manual

What is going wrong?

Press / to search from any page, Esc to close