WooCommerce race bugs taught us to treat cache like a state machine
Monday 2:14 AM: “Why are orders going through when stock says out?”
It started like a lot of production incidents: alerts were noisy, but the root cause wasn’t. On a Fortune 500 apparel brand’s WooCommerce site (high-traffic drops, tight fulfillment windows), we’d recently modernized their checkout performance and moved more caching to the edge. Sales were good, but support tickets spiked: a small slice of customers were seeing “Out of stock” on the product page, then successfully checking out anyway.
The conventional wisdom is “stock checks are eventually consistent, so don’t worry about it.” I disagree—eventual consistency is fine for read models, but not for the decision that creates an order and reserves inventory. In our case, we saw 0.8% oversell on a 32-minute window during the flash-sale—small number, but the downstream cost (manual reconciliation + fulfillment adjustments) was immediate.
The race condition we actually shipped
We had two parallel paths that both decided “inventory is available”:
- Page-level logic used cached product/stock data to render add-to-cart state quickly.
- Checkout-level logic used a WooCommerce hook chain that was intended to reserve stock.
The bug wasn’t “WooCommerce is broken.” The bug was that our optimization accidentally introduced a race between:
- a fast cached read (edge cache + app cache), and
- the actual stock decrement/reservation at checkout.
Under real traffic, two requests for the same SKU could land on different workers. One worker decremented stock; the other worker had already passed a pre-check based on stale stock values. Both then proceeded to create the order/reservation. The pre-check was “technically correct” per its own view of the world—just not globally correct.
Surprising failure mode: the logs looked clean. Both requests wrote successfully, and we didn’t see errors. The inconsistency only surfaced later when fulfillment compared the reserved quantities against warehouse picks.
Why the fix wasn’t “turn caching off”
Turning off caching would have stopped the symptom, but it would have burned latency and increased costs. We had performance targets (and they mattered to conversion). Also, the issue wasn’t really the cache—it was the decision happening on data that could be stale.
So we treated the stock decision as a transaction boundary and moved the logic to a single authority.
What we changed (practical)
- We removed the “available-to-buy” pre-check from the cached layer. Cached reads can power UI, not authorization.
- We made checkout reserve inventory based on an atomic operation, then validated the result before allowing the order to complete.
- We added idempotency protection so the same cart submission couldn’t double-reserve when a retry happened mid-flight.
In the PHP/WooCommerce world, the key is to stop pretending that hook order is your concurrency control. Hook order is for correctness under single-threaded assumptions. Under real traffic, it’s just a sequence of functions running in parallel across workers.
Once we forced inventory reservation to be atomic and tied to idempotent checkout handling, the oversell rate dropped from 0.8% to effectively ~0.01% (noise level from already-in-flight orders during the transition).
Concrete debugging pattern we now run
When we see “page says out-of-stock but checkout succeeds,” we don’t start with stock tables or random Woo hooks. We run a specific trace pattern:
- Pick one SKU and capture a 20-minute window of traffic right at the failure start.
- Extract order_id (or WC order attempt id), SKU, and request correlation id (we inject one in middleware and propagate via checkout).
- For each attempt, record the timestamp of: pre-check decision, reservation write, and order creation.
- Compare those times across concurrent workers to look for “pre-check before reservation by another request.”
Most teams get this wrong because they only compare final stock values. The race is in the timeline, not the end state.
The rule we now enforce so this class doesn’t ship again
Rule: Any WooCommerce decision that creates money or reserves inventory must be based on an atomic server-side operation, and cached reads may only affect the UI—not the authorization path.
One-sentence quote for Monday morning: “If the decision behind checkout isn’t transactionally validated on the server, caching will eventually turn into oversells.”
Saturday 11:07 AM: ISR cache invalidation “worked” until it didn’t
Different team, same pain: a headless WordPress setup feeding a React storefront. We were using an ISR-like strategy (incremental static regeneration semantics) for product and cart-relevant pages. The site looked perfect during QA. Then a regulated beverage portfolio had a price change tied to a promotion calendar. The product pages kept showing the old price for minutes, while the cart total updated correctly.
Customer-visible mismatch is a conversion killer even if it’s “only” display. In one incident, we measured 3.2% drop in add-to-cart completion during the promotion window, largely because users saw a mismatch between product display and checkout totals.
The failure mode
The conventional wisdom says, “ISR will eventually be consistent.” True, but that’s not the right contract for pricing. The real failure was cache invalidation timing plus incomplete tag coverage:
- We invalidated cache based on “product updated” events from WordPress.
- But the price surfaced through a meta field change (promotion rule), and our invalidation pipeline didn’t include that meta key in the dependency graph.
- Result: ISR served stale HTML with the old price while the cart logic pulled fresh price via API.
The surprising part: there were no error logs. Everything was green. “Eventually updated” happened, just not within the business window.
What we did instead
- We created a deterministic invalidation mapping: each cache entry depended on a specific list of WordPress meta keys and taxonomy terms.
- We added a “cache version” token into the API response used by the headless storefront so the UI could detect stale caches and force a refresh.
- For pricing-critical fields, we stopped relying solely on ISR HTML and used API-backed rendering with strict cache TTLs.
Tradeoff: we accepted slightly higher origin/API calls for pricing pages. The alternative—wrong price presentation—cost far more.
Concrete debugging pattern we now run
When stale content shows up “only sometimes,” we don’t guess at invalidation. We run a two-layer trace:
- Record cache key + cache timestamp for a failing page view (from CDN and from the ISR layer if available).
- Record the WordPress update event (post ID, meta key changed, term updates) and correlate to the cache refresh schedule.
If the WordPress event doesn’t map to the cache key’s declared dependencies, you’ll see it immediately. No need for weeks of speculation.
The rule we now enforce so this class doesn’t ship again
Rule: Treat ISR cache invalidation as an explicit dependency graph—every cached field must declare its WordPress source(s), including meta keys used for pricing/promo state. If you can’t name the dependency, you can’t safely cache it for business-critical UI.
One-sentence quote for Monday morning: “If pricing is cached, the invalidation must be field-level deterministic, not ‘post updated’ hopeful.”
Friday 4:33 PM: ACF fields that only break under real traffic
This one hurt because it felt like “random WordPress weirdness,” which is exactly the category that drains time. In an agency client migration (GMR Learning-style training content patterns, anonymized), we modernized a WordPress site that uses ACF to define program cards, schedule blocks, and “read more” CTA behavior. Everything worked in staging. Under real traffic—especially when concurrent editors updated content—some pages would render missing blocks, and others would show duplicated modules.
We measured ~1.1% of page views in production hitting a rendering state that didn’t match the editor preview. Not huge, but it created a support loop and messed with crawl consistency.
The edge case
The ACF issue wasn’t the fields themselves. It was the interaction between ACF update timing and our content transformation pipeline:
- We used ACF flexible content / repeater fields.
- Our headless transformer (PHP in a Laravel API layer) cached the normalized output per post ID.
- During edits, ACF saves can temporarily exist in intermediate states (partial repeater rows, changed layouts) for a brief moment.
Under low traffic, the transformer rebuilt after the final save. Under real traffic, a crawler or user request hit while ACF had only partially completed the update—so our normalization locked in an incomplete representation. Then we served it from cache.
The failure was a race condition again, but this time it lived in content authoring semantics, not checkout inventory.
What we changed
- We stopped caching normalized ACF output until we could confirm the post’s update was “stable.” Practically: we keyed the cache on post modified timestamp + a hash of relevant ACF field groups.
- We made cache writes idempotent and “last write wins” for the same modified timestamp.
- For editor-driven updates, we added a short delay-and-retry when reading ACF during normalization (only for the admin workflow windows, not for all traffic).
In Laravel, this looks like a deterministic cache key and a refusal to serve cached normalization if the post modified time changed since the cache entry was built.
Concrete debugging pattern we now run
When ACF “sometimes” renders the wrong structure, we run a consistency audit:
- Pick one affected post ID.
- Capture: ACF field data raw (via WP REST or direct query), post_modified_gmt, and our normalized output cache key.
- Force replay: trigger the normalization rebuild and compare the output before and after the post_modified timestamp updates.
- Look for output that corresponds to an earlier modified time (classic sign you cached an intermediate state).
Most teams get stuck because they only compare rendered output, not the time alignment between authoring state and cache key validity.
The rule we now enforce so this class doesn’t ship again
Rule: Never cache ACF-normalized output using only post ID. Include a “stability key” that changes on relevant ACF updates (post modified timestamp and/or a hash of field-group values). If your cache key can stay the same while the underlying field state is temporarily inconsistent, you’re going to ship a ghost bug.
One-sentence quote for Monday morning: “ACF can be temporarily inconsistent during writes, so cache keys must incorporate write completion signals—not just post IDs.”
Closing: one debugging pattern, three classes of production bugs
Across WooCommerce oversells, headless ISR staleness, and ACF rendering mismatches, the common root isn’t “WordPress is messy.” It’s that we were treating time and state as if they were deterministic. They aren’t in production. Multiple workers, multiple caches, and multi-step authoring make races inevitable.
Our shared debugging pattern is always the same: reconstruct the timeline (decision time vs write time vs cache build time) and make the “authority” for critical decisions explicit and atomic.
That’s the line we draw: if it affects money, pricing, or correctness of structured content, it can’t rely on optimistic cached reads or post-ID-only cache keys.
One sentence you can quote: “When production breaks, don’t chase the symptom—chase which system made the decision first, with what state, and under what cache key.”
How we work at Champlin Enterprises: we build with state machines in mind and we test for cache/key/timeline correctness as first-class requirements, because that’s where the real failures hide—see our projects for examples across WordPress/WooCommerce modernization and our applied-AI platforms.