A slow category page costs you a bounce. A slow checkout costs you a paid-for order, and the official Stripe plugin will sit on one for 70 seconds before it gives up. Checkout runs live PHP on every view and has to: the page shows one customer his cart, his address, his totals, so a cached copy is somebody else’s order. It also waits on other companies’ computers, one blocking call at a time, because each call needs the previous one’s answer. Different problem from general store slowness. Fifteen causes, grouped by symptom, each with its fix or the reason there isn’t one.
Three tests tell you which group is yours.
- Page load. One item in the cart, private window. Time your category page, then checkout. Category fast, checkout slow: group 1.
- Totals. Type a full address, leave one field, count the seconds until the summary settles. Past a second or two: group 2.
- The click. Buy your cheapest product with a stopwatch, once quiet and once at your busiest, then refund it. Long spinner with group 2 already clean: group 3. Place Order reruns the totals stack, so a group-2 cause lands here too.
The 20-Minute WooCommerce Speed Audit is the instrumented version.
Group 1: the checkout page is slow to load

1. Checkout has no cache to hide behind
Every line belongs to one shopper, so a stored copy hands him someone else’s cart. Core sets DONOTCACHEPAGE on cart, checkout and account (WC_Cache_Helper::set_nocache_constants()), the Store API sends no cache headers, and a CDN rule that ignores headers still serves the wrong cart. Which is a worse bug than a slow page.
Spot it: compare TTFB on a product page against checkout.
Fix: none at page level. Cut what runs inside instead. Fragment and response caching still apply (causes 3 and 7); what is left is your live-PHP floor.

2. The store underneath is slow
Too few PHP-FPM workers, a bloated autoloaded options table, transient sprawl, a heavy plugin stack: multi-second checkout with zero external calls involved.
Spot it: the category page in test 1 was slow too.
Fix: whole-store problem, fix that first. Why Your WooCommerce Store Is Slow covers workers, TTFB and autoload bloat.
Running a WooCommerce store?
A decade of keeping WooCommerce stores fast, stable, and quietly making more money. Here is how I think about the technical side of running one.

3. Cart fragments hammering the uncached path
Themes and plugins still enqueueing the classic cart-fragments script fire wc-ajax=get_refreshed_fragments, which boots WordPress and recomputes the mini-cart even when the page around it came from cache. Block checkout on the Store API skips this.
Spot it: a slow get_refreshed_fragments request in the network tab.
Fix: stop it firing where no mini-cart renders, or hydrate a count from client-side storage.

4. The form itself is expensive to render
Some field editors and builder integrations rebuild form config on every render and again on each AJAX refresh, and the Checkout Block bundle plus a page-builder wrapper adds payload on top.
Spot it: deactivate the field editor on staging and re-time the render.
Fix: move relabels into a woocommerce_checkout_fields snippet (shortcode only), strip builder wrappers, dequeue the rest, retest validation and payment.
Group 2: the totals lag while the customer fills the form

5. One field change recalculates everything
Fields affecting location, shipping or totals fire update_order_review() (shortcode) or cart/update-customer (Store API), each running the full shipping, tax and totals stack on the uncacheable path. Plugins add listeners that turn one change into several requests.
Spot it: network tab while typing an address. One settled request per change, not a queue.
Fix: remove duplicate plugin listeners, debounce noisy custom ones without suppressing the final update.

6. The totals calculation has no re-entrancy guard
WC_Cart::calculate_totals() has no flag, no semaphore, no static, not even a docblock warning, and neither does WC_Cart_Totals::calculate(). A plugin adjusting a fee mid-calculation re-enters the chain from the top. That re-entry is the cascade.
Spot it: log entry, exit and call depth on woocommerce_before_calculate_totals. Re-entrancy means the hook fires again before the previous pass exits.
Fix: move the re-entering plugin to a hook that runs once. One re-entry costs one extra pass; the damage is plugins that re-enter on every pass. On heavy stacks I have seen those chains run near 30 seconds: field experience at the bad end, unmeasured.

7. Live carrier and tax APIs, called per recalculation
Rate plugins and tax services (Avalara, TaxJar) call out over the network during totals. Rates cache against a package hash (class-wc-shipping.php), and a plugin mutating line items invalidates that hash, forcing a fresh call.
Spot it: change the shipping country and time the settle. That path cannot hit core’s rate cache.
Fix: cache rates by zone and postcode for display, and let the tax service record the final calculation at order creation. A changed address still needs a live one: the customer must never see totals different from what he is charged.

8. Coupon and discount rule plugins
Dynamic pricing, BOGO and cart-condition plugins re-evaluate their whole rule set on each recalculation, and a rule that calls totals again mid-pass feeds straight into cause 6.
Spot it: time a coupon apply, then again with the rule plugin off.
Fix: delete unused rules, and drop plugins that write cart items during the fees hook.
Group 3: the spinner after Place Order

9. The gateway timeout your plugin actually uses
Core defaults to 5 seconds (WP_Http::request()) and none of these four inherits it. Version stamped, they drift per release.
| Gateway | Plugin version | Timeout ceiling |
|---|---|---|
| Mollie | 8.1.9 | 10 seconds |
| PayPal Payments | 4.1.2 | 30 seconds |
| Square | 5.4.3 | 60 seconds |
| Stripe (official) | 10.8.5 | 70 seconds |
PayPal retries once on a 401 or NOT_AUTHORIZED 403, so a slow response ending in one costs up to 60 seconds; a plain hang costs 30.
Spot it: search your gateway’s API class for timeout. In the official Stripe plugin it is a bare integer on wp_remote_post(), with no Stripe-specific filter around it.
Fix: treat your version’s number as the worst-case wait for one gateway call. Core’s http_request_args filter runs after the plugin merges its arguments, so you can override it, but cutting the wait short means walking away from charges that complete anyway: a reconciliation decision. Authorize.Net and Braintree are off public SVN, unread, and I will not guess.

10. Your web server gives up before PHP does
max_execution_time does not count blocking network waits on Linux, PHP-FPM’s request_terminate_timeout ships at 0, and nginx quits first at its default fastcgi_read_timeout of 60 seconds, ten seconds before the Stripe plugin. The worker keeps running and the charge can still complete. That is the orphan-charge window.
Spot it: a 504 with a matching successful charge in the gateway dashboard.
Fix: check that dashboard before refunding, make order creation idempotent so a retry is safe, then align the read timeout with your gateway’s deliberately. A 70-second hang holds a worker for 70 seconds, so raising timeouts site-wide trades 504s for worker starvation. Pick which one you would rather explain.

11. 3DS and SCA redirect round-trips
The strong-authentication challenge sends your customer to his bank’s page, where the wait turns human and unbounded, and comes back to you as “checkout was slow”.
Spot it: compare completion times for cards that trigger a challenge against cards that do not.
Fix: you cannot remove issuer-required challenges in Europe, though exemptions and frictionless 3DS avoid a visible one. Make the redirect obvious so he knows he is at his bank, and stop counting that time as your store’s.

12. Confirmation emails sent synchronously
WC_Email::send() calls wp_mail() directly in the base class, so your customer waits through whatever it does, SMTP handshake included, and gets nothing from it.
Spot it: point mail at a local sink on staging and re-time the spinner.
Fix: queue emails through Action Scheduler, once you have confirmed a real system cron. An API-based transactional service is the consolation prize: still blocking, just cheaper than the handshake.

13. Integrations firing their own calls on order creation
Core’s webhooks already defer, queued through Action Scheduler by default (woocommerce_webhook_deliver_async), so a backlog delays your warehouse and never this request. The risk is ERP, warehouse and Zapier-style plugins that hook order creation and call out inside it.
Spot it: trace a test order on staging, looking for outbound calls between order creation and the response.
Fix: prefer integrations that queue, and move the direct ones onto Action Scheduler. No warehouse needs the order in the same second the customer does.

14. CRM, tracking and validation services on checkout hooks
Klaviyo, HubSpot, cart-recovery tools, server-side pixels, GA4 measurement calls, fraud scoring, address validation: any of them can make a blocking call inside the request the customer is waiting on, with timeouts you never chose.
Spot it: deactivate them one at a time on staging, timing a test order after each.
Fix: queue server-side purchase events with idempotency keys, client-side only where losing events to ad blockers is acceptable. Queue the CRM sync, and give live validators a short explicit timeout that fails open.

15. Stock decrement contention
The oversell race is at the application layer: stock is validated early and decremented later, core takes no lock across that gap, and another order can pass validation in between (GH #12467). The decrement takes InnoDB row locks, and under a flash sale that is what concurrent orders queue on.
Spot it: the busy-hour run is slower than the quiet one and the slow query log shows stock updates waiting on row locks, or you wake to oversold SKUs. A busy-hour delta alone is cause 2’s signature too.
Fix: leave this one blocking. Defer it and you reopen exactly the oversell window this cause is about. Cut other queries out of the same transaction window. This is the one cause on the list where the slowness is the feature.
The principle behind all fifteen
Three things have to finish before the thank-you page: the payment authorization, the order record and the stock decrement. Fraud scoring or address validation before authorizing makes a fourth, so give it a short fail-open timeout rather than deferring it. Emails, webhooks, CRM sync, fulfillment pings and analytics all run fine after the response goes out. Deferred work reaches you in 1 to 5 minutes through Action Scheduler with a real system cron, and unbounded without one. Set the cron first, then start moving things off the request.
Key takeaways
- Checkout can never be page-cached. Cart, checkout and account all carry
DONOTCACHEPAGE, so the only lever is cutting what runs inside the request. - The worst-case wait is whatever your gateway plugin sets. Stripe’s official plugin ships at 70 seconds and nginx quits at 60, which is how a customer gets a 504 and a charge at the same time.
WC_Cart::calculate_totals()has no re-entrancy guard. A plugin that re-enters on every pass is what turns a two-second settle into a cascade.- Only three things have to finish before the thank-you page: the payment authorization, the order record and the stock decrement. Everything else defers, once a real system cron is running.
- Time the click at your busiest hour as well as your quietest. An undersized stack and stock contention both hide until load arrives, and they look identical until you read the slow query log.
Checkout is the one page you cannot cache and still get paid on. Every second you take out of it comes off your own stack, because none of the other companies in that chain are going to hand one back.
Where the numbers come from
All read 2026-08-17. Gateway timeouts are plugin values and drift per release, so each carries its version.
- Gateways: Stripe 10.8.5,
'timeout' => 70, hand-rolled on the WordPress HTTP API with no plugin filter · PayPal Payments 4.1.2,RequestTrait.php, 30s plus one retry on a 401 orNOT_AUTHORIZED403 · Mollie 8.1.9, 10s · Square 5.4.3, 60s. Authorize.Net and Braintree are commercial extensions off public SVN, so unread. - Stack defaults: core’s 5 seconds, overridable through
http_request_argsafter the plugin’s merge ·max_execution_timeignoring blocking network waits ·request_terminate_timeoutat 0 · nginx’s 60 seconds. Cloudflare’s 125-second proxy read timeout sits above every row, so nginx still quits first behind it. - WooCommerce core:
set_nocache_constants()·WC_Cart::calculate_totals()andWC_Cart_Totals::calculate(), no guard ·class-wc-shipping.phppackage-hash rate caching ·WC_Email::send(), whosewoocommerce_mail_callbackdefaults towp_mailin-request · webhook delivery collected atshutdownand queued through Action Scheduler by default, which is why cause 13 is about third-party calls · GH #12467 for the check-then-decrement race; the InnoDB row locks are standard MySQL behavior, not a source read. - Unmeasured, and stated as such: the cascade’s re-trigger count and per-pass cost, so “near 30 seconds” is field experience only · external-call time as a share of checkout time · gateway latency medians, which no vendor publishes · the current Checkout Block payload · Avalara and TaxJar latency.






Leave a Reply