Load bugs don’t arrive politely; we fixed ours by removing one assumption
At 9:42am on a Monday, a Fortune 500 apparel brand told us their checkout page started throwing sporadic 500s—only during a live email blast. We weren’t even looking at checkout initially; we were watching caching hit rates because the site had just been modernized from legacy WordPress into a cleaner WordPress + WooCommerce setup. The dashboards showed an error rate climbing to 0.7%, while staging stayed at essentially 0.00%. That mismatch is the whole point of this post: load bugs don’t show up because “you need more testing.” They show up because production has failure modes your test rigs don’t model.
The decision we made that day wasn’t to add more cache. It wasn’t to “tune PHP-FPM.” We did something that most teams consider backwards: we removed one assumption from our architecture—that “idempotent means safe under concurrency.”
The incident: WordPress/WooCommerce + AI scoring + race conditions
The store had an operational requirement: every checkout session pulled an internal “recommendations” payload. During the modernization, we moved recommendation generation into an async pipeline, but we still needed the results in the request path sometimes. So we had a hybrid: fast cache hits first, and if empty, we’d generate on-demand.
On paper, it sounded safe. In practice, the cache fill code did not guarantee single-writer behavior. The logic was roughly:
- Compute a cache key from user/cart attributes.
- If cache miss, generate recommendations (and in some flows, call an LLM reranker).
- Write result into cache.
Under load, multiple concurrent requests for the same cart state all hit the miss, all triggered generation, and then they raced writing to the cache. Usually the “last writer wins” and everything looks fine. The failure mode was nastier: one writer would store a partially built payload (because generation included streaming intermediate steps to persist token-by-token progress), while another writer would later overwrite it with a complete payload. That should still be safe. The bug was that checkout sometimes read the cached payload while it was being written—and we used a cache backend mode that didn’t provide atomicity for our write pattern.
Result: transient 500s during the blast. Error rate peaked at 0.7% and then dropped back down within minutes after the cache warmed—exactly why staging never caught it. Staging didn’t have the same concurrency, the same cold-cache bursts, or the same timing between write and read.
Conventional wisdom we rejected
Conventional advice in the WordPress/WooCommerce world is “add cache, tune TTLs, and you’ll stabilize performance.” I’m not against caching. I’m against the version of caching that assumes your app is already correct under concurrent writers.
Our mistake wasn’t “we cached.” It was that we treated cache writes as if they were inherently atomic and idempotent. They weren’t. And any AI-integrated pipeline that writes intermediate progress makes this worse, because you’re no longer just caching final values—you’re caching the lifecycle.
What we changed permanently
We made two permanent changes, both simple, both annoying, and both effective.
1) Single-writer cache fills using a lock + write-after-complete
We changed the cache fill path to follow a strict rule: never publish until the payload is complete.
- We introduced a short lock keyed by the cache key (Redis-style lock or DB advisory lock, depending on deployment).
- Only the lock owner generates recommendations.
- Other requests either wait with a tight timeout or serve a degraded-but-valid fallback (e.g., cached “last known good” recommendations).
- We write to cache only after the full payload is built.
This removed the race condition completely. We also changed the AI reranker integration to buffer results locally and only then persist. No more token-by-token persistence in the request path. If we need progress persistence, it goes to a separate “in-progress” channel that checkout never reads.
2) Cache entries include a schema/version stamp
Once we fixed the race, we ran into the second “load only” issue: stale or incompatible cached payloads surviving longer than expected after code changes.
Under load, the cache isn’t just a speed layer—it’s a versioning layer you didn’t design. In our case, schema changed during the modernization (recommendations payload shape). The cache key was derived from user/cart attributes and didn’t include a schema stamp. During a deploy, you get a brief window where some workers can write old format and some can read new format. At low traffic, you might never see it. At burst traffic, you do.
So we permanently versioned cache keys with:
- recommendation pipeline version,
- payload schema version,
- AI model/reranker version (where relevant).
That turned a “mystery” class of 500s into deterministic cache misses.
Numbers: the fix that mattered
After deployment of the new single-writer fill + publish-after-complete rule, that same brand’s next promotional blast (we watched the exact same customer cohort timing) went from 0.7% 500s down to effectively noise-level: <0.02% transient errors, with the remaining failures tied to unrelated upstream payment timeouts.
More importantly: the “warm up” behavior stabilized. Instead of a cache-miss stampede causing a thundering herd, the system behaved like this:
- One writer pays the generation cost.
- Concurrent requests either wait briefly or serve fallback.
- Once ready, all requests read the complete payload.
We didn’t just reduce errors. We reduced variance. And variance is what breaks checkout under real load.
A second war story: the bug only showed up in AI Showcase
This theme showed up again in our own AI Showcase (Laravel 11 + Livewire 3). We had a feature where users could explore multi-model outputs with kill-switches and budget guardrails. The system had to be safe under load because a single runaway request could burn the monthly budget.
We initially tracked “requests in flight” using an in-memory counter per app instance. It was “good enough” until we deployed multiple PHP-FPM workers behind a load balancer. Under load, each instance thought it was the only one enforcing the budget. The guardrails were correct per-instance and wrong in aggregate.
In production, the error manifested as: no immediate failures, but budget enforcement kicked in too late. The guardrail logic fired after the budget was already partially consumed, because the counters were non-shared. You could see it in logs: each instance stayed under its limit, and the cluster exceeded the real limit.
The permanent fix was to move the budget counters into a shared store with atomic increments (Redis/Memcached with Lua/transactions, or a DB row with row-level locking). That change feels boring, but it was the only way to make “safety” actually mean safety under concurrency.
What I tell teams now
- If multiple requests can compute the same thing, you need a single-writer strategy. “Idempotent” is not the same as “race-safe.”
- Don’t let checkout (or any critical request path) read intermediate state. AI pipelines often create intermediate artifacts; keep them out of latency-critical flows.
- Cache keys are contracts. If you change payload shape or model behavior, version your cache. Otherwise, you’ll debug ghosts under load.
- Cluster safety requires shared state. In-memory counters lie when you scale out.
Monday-morning quote
“Most load bugs aren’t about traffic—they’re about concurrency assumptions; fix the single-writer/race problem and cache versioning, and the errors stop behaving like ghosts.”
At Champlin Enterprises, we treat these as architectural invariants, not patch notes: when WordPress/WooCommerce modernization or a Laravel applied-AI feature needs correctness under load, we design for concurrency from day one rather than “tuning” until it stops failing. Champlin Enterprises projects