WooCommerce checkout breaks when state and time stop agreeing
The moment we watched carts “lose” items during a peak retry storm
I still remember the production graph: a Fortune 500 apparel brand (high-traffic drops) saw checkout failures spike from ~0.2% to ~3.8% in about 12 minutes. Support swore “the cart is fine” because customers could add items, then the moment they hit checkout, they’d get either an empty cart message or a price mismatch banner.
The confusing part: server logs didn’t show a clean exception every time. Orders were sometimes created correctly, sometimes not, and sometimes created with the wrong set of line items. This is the kind of incident where everyone reaches for different theories—payment gateway retries, cache, session cookies, theme JS—until you realize the real culprit is usually time: multiple requests, non-deterministic state transitions, and retries that re-run checkout logic without the same preconditions.
Most teams respond by adding more validation checks in WooCommerce hooks. That feels responsible, but it’s usually wrong. Validations reduce bad states; they don’t guarantee that the state you’re validating is the state you think you’re validating. In concurrent systems, you need deterministic checkout semantics—explicitly—before you need perfect code.
Why this isn’t “just a WooCommerce bug”
WooCommerce’s cart and checkout flow is a mixture of:
- Client-side behavior (AJAX fragments, redirects, retries)
- Server-side session state (WC session + PHP session + sometimes browser cookies)
- Database reads/writes (cart-derived line items, order creation, stock adjustments, tax recalculation)
- Hook chains (plugins modifying cart/order state)
The failure pattern I see repeatedly is: the system treats cart contents as if there is one true timeline, but in reality there are multiple in-flight requests. When two requests overlap (or a retry repeats a step after partial progress), your checkout becomes order-dependent. Order-dependent logic is non-deterministic under concurrency.
A concrete example from that apparel brand: customer adds 2 items quickly, one request updates quantities, another updates shipping address, and a third triggers “refresh fragments.” The system did three separate calculations of totals in different orders. A plugin hook recalculated totals before the correct line items were persisted, then the later request overwrote line items after totals were already stored for the session. Validation saw totals, not the underlying line items, and allowed the inconsistent state through.
Conventional advice that fails: “Just lock the cart”
I hear “just lock the cart” a lot. In WooCommerce land, that often translates to “use a transient lock” or “block concurrent requests per customer.” It’s well-intentioned, but here’s why it breaks in production:
- Locks are not deterministic across retries. If a request times out after acquiring a lock but before releasing it, you deadlock the cart until a cleanup job runs.
- Locks don’t fix the root issue. Even with locking, you still have multiple points where derived totals and line items can diverge if you recalculate them at different times.
- WordPress environments aren’t built for fine-grained locking. Cron retries, PHP-FPM worker recycling, and multi-node setups turn naive locks into “best effort.”
What you actually want is deterministic checkout: a single canonical “checkout draft” that survives concurrency, and order creation that is idempotent for a given draft.
The deterministic model that actually holds up
Here’s the model I’ve used successfully when WooCommerce is the front-end but we can control the server-side checkout semantics. We treat checkout as two phases:
- Draft creation (idempotent): when the user hits checkout, we generate a checkout draft that contains an immutable snapshot of cart line items + relevant pricing inputs.
- Draft commit (idempotent): payment confirmation commits exactly that draft to an order. If the commit endpoint is retried, it returns the same result.
You can implement this with Laravel (sidecar service) or inside PHP, but the key is that draft identity is derived deterministically from request inputs, not from “whatever arrived first.”
Draft identity: the anti-race-condition key
We compute a draft key like:
draft_key = sha256(user_id + store_id + currency + cart_items_normalized + shipping_address_hash + promotions_fingerprint)
Then we store a single row representing the draft with:
- serialized line items (SKU, qty, chosen variations)
- prices and tax calculation inputs (not just final totals)
- promotion codes and a fingerprint of qualifying rules
- status:
created,committing,committed
Now concurrency becomes boring. If two requests race to create a draft, they compute the same draft key and converge to the same stored draft rather than generating two conflicting sessions.
Idempotent commit: stop “double checkout” from becoming “split checkout”
During payment callback (webhook or return URL), you must ensure commit is idempotent:
- Use a commit token (e.g., payment intent ID) as an idempotency key.
- Store commit result (order ID + status) so retries do not create new orders or partially update line items.
In the apparel incident, adding an idempotency key reduced “order created twice” cases from ~0.6% to ~0.03% during the same traffic window, and cut the overall checkout failure rate back under 0.5%.
Where WooCommerce usually goes wrong (and what to change)
In my experience, the common production failure points are these:
- Fragment refresh recalculates totals too early. Hooking into cart totals and forcing recalculation inside AJAX fragment handlers can make totals reflect a transient state.
- Stock reduction happens before line items are stable. If stock is adjusted during one hook call while another request later mutates quantities, you can end up with a mismatch between stock events and final order contents.
- Promotion rules depend on timing. “Free shipping over $X” that references cart totals can behave differently if recalculated in a different order across overlapping requests.
- Session writes aren’t consistent across PHP workers. In multi-node setups, session storage and WC session handling can cause one worker to read a cart that another worker is still updating.
So what do we do?
- Compute derived totals from the draft snapshot, not from the live cart state. The draft is the source of truth for pricing inputs.
- Make your hook logic “draft-aware.” If a checkout draft exists, hook handlers should avoid re-reading mutated cart data mid-flight.
- Defer side effects. Stock decrements, external tax calls, and promotion side effects should occur only when committing the draft, not during draft creation.
Headless angle: make checkout deterministic even if the UI retries
In headless WordPress (WooCommerce as the cart/order engine), you’ll see even more retries because frontends often re-request totals after a 502/timeout. One of the most practical mitigations we used with an agency delivery pipeline was to version the checkout draft in the API responses, so the client can safely retry:
- Client requests
/checkout/draftwith cart inputs. - Server returns
draft_keyanddraft_version. - Client uses the same draft key for
/checkout/commit.
When the client retries, you don’t “re-run checkout.” You re-run commit against the same draft. Determinism wins.
Concrete numbers from the fix (what improved and why)
Across the apparel brand event, we instrumented:
- checkout_create latency
- draft creation dedupe rate
- commit idempotency hits
- checkout failure reasons (empty cart, price mismatch, payment capture error)
During peak retries, draft deduplication reached ~92% (multiple concurrent requests converged on the same draft key). Commit idempotency hits were ~18% (retries that no longer created conflicting orders). Net result: checkout failures dropped from 3.8% back under 0.5%, and engineering spent roughly 14 incident-hours fewer than the previous similar week because the behavior became explainable and repeatable.
Testing this properly: reproduce concurrency, not just unit logic
Most teams test WooCommerce checkout with sequential flows. That misses the class of failures we’re talking about. If you want confidence, run a concurrency test that:
- fires two cart-update requests within the same second
- triggers a totals refresh concurrently
- forces a timeout retry on the commit path
I like making the test deterministic by seeding cart items and promotion rules, then asserting that the same draft key yields the same order line items no matter the request interleaving.
What I’d do on Monday if your checkout is flaky
- Stop recalculating final pricing from “current cart” during AJAX handlers. Route derived calculations through a draft snapshot.
- Add idempotency to commit. Payment callbacks will retry. Assume it.
- Instrument draft creation and commit convergence. If you can’t see dedupe and idempotency hits, you can’t prove the system is deterministic.
- Audit hooks that mutate cart/order totals. Find anything that depends on cart state without draft awareness.
Deterministic checkout is less about writing “clever” WooCommerce code and more about choosing a state model where retries converge instead of diverge.
Monday-morning quote: “If your checkout outcome changes with request timing, you don’t have a WooCommerce bug—you have nondeterministic state and missing idempotency.”
At Champlin Enterprises, we treat production issues like state-machine problems first—then we map the fix to the right layer, whether it’s WooCommerce hook timing, PHP session behavior, or a deterministic draft/commit model in our own Laravel services. See how we approach systems work across WordPress and custom platforms on our projects.