WooCommerce checkout race conditions: inventory desync is the symptom
Monday 10:17am: the day inventory went negative on a Fortune 500 apparel store
We were modernizing a Fortune 500 apparel brand’s WooCommerce checkout flow through a “headless-ish” setup: WordPress + WooCommerce for catalog and orders, a separate frontend doing some pre-checkout UX, and a lot of AJAX endpoints that tried to be helpful. Everything looked fine in QA. Then a single weekend promo hit, and we watched inventory behave like a haunted spreadsheet.
The first alarm wasn’t support tickets—it was the admin UI showing 9,842 units sold for a SKU that had 8,000 available. Within 12 minutes, we also saw 31 duplicate charge attempts in payment logs and 7 “phantom orders”: orders that existed in WooCommerce but never successfully transitioned to “paid” in the provider.
Conventional wisdom says, “Don’t worry, payment providers handle idempotency.” That’s only half true. The real failure wasn’t the payment gateway—it was our checkout doing read-then-write with loose concurrency boundaries. Inventory desync was the symptom. The root cause was a race condition we accidentally introduced when we optimized perceived responsiveness.
My take: treat WooCommerce checkout like a database transaction, not UI glue
Most teams get this wrong because WooCommerce makes checkout feel like a web form, so we wire it like one. But in production, checkout is a distributed state machine:
- cart totals are computed from pricing + tax rules + coupons
- stock availability is checked (and often cached)
- payment intent / confirmation happens asynchronously
- order status transitions happen after webhooks or redirects
- side effects (stock decrement, fulfillment triggers, invoice generation) happen at different hooks
If you don’t make those transitions serializable per order and per SKU, you will eventually get duplicates, phantom orders, or both.
Here’s the specific mistake we found: we had two concurrent requests doing stock reads. One request came from the frontend “reserve stock” endpoint (AJAX). The other came from WooCommerce’s normal checkout processing when the user clicked Pay. Both wrote back using normal meta updates and relied on the default WooCommerce flow to “just work.” Under load, those writes overlapped.
How the race condition showed up: inventory desync → duplicate charges → phantom orders
1) Inventory desync
Stock decrement was happening in more than one place. One code path ran on cart/checkout view (to show availability), another ran during order processing, and a third ran via a webhook handler that retried on transient provider errors.
Under concurrency, you get a classic interleaving:
- Request A reads stock = 10
- Request B reads stock = 10
- A decrements to 8 and writes
- B decrements to 7 and writes (based on stale read)
Net effect: you sold 3 units while only decrementing 2 correctly. Multiply by wave of promo traffic and you land in negative inventory or “sold more than owned” territory.
2) Duplicate charges
The “duplicate charges” part surprised people, because we already used the provider’s idempotency key in one code path. But a second path didn’t share the same idempotency input. When the frontend did a retry after a slow response, it hit a different endpoint that minted another payment intent.
So you don’t just need idempotency on the payment provider. You need idempotency at your state machine—the order creation + payment intent creation must be deduped using the same stable key.
3) Phantom orders
Phantom orders were the order records created early (on “place order”) while payment confirmation never successfully finalized. The order stayed in a limbo status because the webhook handler assumed the order meta was present, but the meta write race caused it to miss. Result: the order exists, looks real in admin, but it never transitions to paid.
Our error rate during the promo window was roughly 0.42% of checkout sessions generating either a phantom or a duplicate attempt. That’s “small,” until your promo pushes volume from thousands to hundreds of thousands of sessions.
Production fix we shipped: locks + dedupe keys + webhook-safe transitions
We fixed this by making checkout deterministic where it matters. We did not try to “fix it with more caching” or “tune retries.” Cache and retries only amplify races. Instead, we narrowed the concurrency surface and enforced invariants.
Step 1: Create order and payment intents with a stable idempotency key
We used a stable key derived from:
- authenticated user id (or guest session id)
- cart signature (items + quantities + selected shipping + coupon codes)
- nonce scoped to the session (to avoid replay across distinct carts)
We stored a mapping table outside WooCommerce order meta for fast checks. On “place order,” if that idempotency key already existed, we returned the existing order id instead of creating a new order.
This alone cut duplicate payment intent creation by ~86% in the next load test.
Step 2: Serialize stock reservation per SKU per batch
Inventory isn’t “just a number” in WooCommerce when you run under concurrency. We introduced a reservation model:
- On checkout, we attempted to create a reservation row per (SKU, idempotency key) using an atomic constraint.
- We computed available units as (total - committedReservations - confirmedFulfillments).
- Only if reservation succeeded did we allow order placement to proceed.
Implementation detail: We used a transaction boundary in the persistence layer for reservations and an atomic insert/update pattern. Yes, WooCommerce stores stock in post meta by default—because it’s easy—but that storage model is not built for concurrency correctness.
We left WooCommerce stock fields as the reporting surface, and treated reservations as the source of truth during the checkout window.
Tradeoff: you add a persistence layer and a bit of complexity. But correctness beats “it probably won’t happen.”
Step 3: Make webhook handlers idempotent and side-effect free
Webhooks are retry-friendly by design. Our prior webhook handler wasn’t. It assumed it would run once and that related meta would already be written.
We changed the webhook flow to:
- verify signature
- use provider event id as a dedupe key
- perform a state transition only if the order is in the expected precondition state
- run side effects (stock decrement/fulfillment) only when moving from unpaid → paid
That killed phantom orders caused by out-of-order events. In practice, it reduced phantom orders by ~90% in the first production week after rollout.
Step 4: Remove concurrency-prone “reserve stock” endpoints from the critical path
We learned something important: frontend “helpers” often become second checkout engines.
We kept a lightweight availability endpoint for UX, but we stopped it from affecting inventory state. Any endpoint that can be called twice must either be idempotent or be strictly read-only. We changed our “reserve stock” endpoint to just return “estimated availability,” not perform writes.
That reduced load and eliminated one extra race vector.
How we instrumented it (so it didn’t regress silently)
We added structured logs keyed by the idempotency key and order id. Then we built a small admin report:
- count checkout sessions by outcome (placed, paid, canceled, webhook failed)
- compare expected reservation success vs actual order transitions
- track provider event id processing to ensure dedupe works
For performance, we cached read-only product/cart availability responses, but we never cached the reservation decision. The real rule: cache reads, don’t cache decisions.
Also: we turned off a WooCommerce-friendly but dangerous setting we’d been using—aggressive transients for checkout totals during promo. It caused “totals drift” across concurrent requests, which then fed into mismatched payment amounts and extra retry storms.
Where headless WordPress and Laravel fit in
We didn’t “replace WooCommerce,” but we did move some of the risky parts into a controlled service boundary when needed. For one internal workflow, we used Laravel to process fulfillment triggers based on order transition events, not on checkout requests themselves.
That separation mattered because Laravel gives us clearer transactional control and easier instrumentation. It also helped with our applied-AI work elsewhere—while our AI agents were analyzing customer-support patterns, we ensured they never touched checkout state. The state machine belongs in deterministic code paths, not “agent reasoning loops.”
In our AI Showcase and AI Tax projects, we built explicit “kill-switch + budget guardrails.” Checkout needed the same philosophy, just mapped to invariants: dedupe keys, precondition checks, and safe retries.
Numbers: what changed after the fix
- Duplicate charge attempts dropped by 86% in load testing and by ~72% during the first real promo rollout.
- Phantom orders dropped by ~90% after we made webhook handlers idempotent and side-effect free.
- We recovered about 6–8 hours of engineering/support time per week during promo weeks by removing the “manual reconciliation” workflow.
Those aren’t “we improved performance” metrics. They’re “we stopped losing money and trust” metrics.
Tuesday morning checklist (the one I actually give my team)
- Do you have a stable idempotency key for order creation and payment intent creation?
- Can the same request be sent twice without creating two orders?
- Is stock reservation serialized per SKU (or otherwise made transactionally safe)?
- Are webhook handlers deduped and guarded by state preconditions?
- Are any “reserve stock” endpoints writing inventory state on the frontend critical path?
- Are you caching checkout decisions (bad) vs caching read-only availability (fine)?
One more war-story detail: if you see this only under promo load, assume you have a race, not a configuration problem—because correctness issues scale with concurrency.
“If inventory desync or phantom orders happen in WooCommerce, stop blaming the payment gateway—fix the checkout state machine with idempotency keys and transaction-safe stock reservations.”
At Champlin Enterprises, we treat production correctness as a design constraint: build deterministic state transitions, isolate side effects, and keep caches and AI systems on the safe side of the boundary—because that’s how we ship modernization across WooCommerce and Laravel without surprises. See our projects.