Why WooCommerce Checkout Is Slow: 15 Causes and What To Do About Each

·

11 min read

·

A shopper at a counter with a stopwatch, three doors behind him labelled PAGE LOAD, TOTALS and PLACE ORDER, wires running off to distant buildings

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.

  1. Page load. One item in the cart, private window. Time your category page, then checkout. Category fast, checkout slow: group 1.
  2. Totals. Type a full address, leave one field, count the seconds until the summary settles. Past a second or two: group 2.
  3. 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

A shopping cart tagged CHECKOUT crossed out in front of a filing drawer labelled CACHE

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.

A shop building sinking into mud under a heavy stack of boxes labelled PLUGINS

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.

A tiny mini-cart pulling a rope that rings a huge bell, waking a sleeping giant labelled WORDPRESS

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.

A bricklayer rebuilding a checkout form out of bricks while a customer waits

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

A large domino labelled ZIP CODE toppling a chain of dominoes labelled SHIPPING, TAX, TOTALS

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.

A calculation loop re-entering itself through an open gate signed NO GUARD, beside a clock reading 30 seconds

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.

A shop counter with two phone wires stretching to distant buildings, a stopwatch on each, labelled CARRIER and TAX

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.

A hand starting again from the top of a tall stack of rule cards, a re-read arrow circling it

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

Four bars of gateway timeouts: Mollie 10, PayPal 30, Square 60, Stripe 70 seconds before it gives up

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.

GatewayPlugin versionTimeout ceiling
Mollie8.1.910 seconds
PayPal Payments4.1.230 seconds
Square5.4.360 seconds
Stripe (official)10.8.570 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.

Two stopwatches: nginx stopped at 60 while Stripe still runs at 70, a torn receipt and a charged card between them

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.

A path leading from YOUR STORE away to HIS BANK, where a customer waits beside a large hourglass

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.

A customer at a counter holding a stopwatch while a postman slowly wax-seals a large envelope

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.

A courier walking one order slip all the way to a distant WAREHOUSE while the customer waits empty-handed

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.

A delivery van driving off dragging four tin cans labelled CRM, PIXEL, FRAUD, VALIDATE

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.

Three hands reaching through a single-file turnstile for the one box left on a shelf, labelled LAST UNIT

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 or NOT_AUTHORIZED 403 · 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_args after the plugin’s merge · max_execution_time ignoring blocking network waits · request_terminate_timeout at 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() and WC_Cart_Totals::calculate(), no guard · class-wc-shipping.php package-hash rate caching · WC_Email::send(), whose woocommerce_mail_callback defaults to wp_mail in-request · webhook delivery collected at shutdown and 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

Your email address will not be published. Required fields are marked *

Get new writing by email

Scale, AI agents, WooCommerce. Signal, no noise. A few emails a month.

A few emails a month. No spam, unsubscribe anytime.

More writing