Developer guide
How does Smart Add-Ons cache-safe A/B testing actually split traffic?
On a page that might be served from a full-page cache, the server never picks a variant per request — it sends both add-on groups, tagged with the test ID and split percentage, and a storefront script decides which one to show using a first-party cookie. The variant itself comes from a four-step fallback: an explicit assignment posted with the form, that cookie, which side's fields were actually submitted, and finally a deterministic crc32 hash of the visitor's session, sticky for the rest of that session.
Smart Add-Ons cache-safe A/B testing solves a problem a naive implementation runs straight into: if the server decides which variant to show and that decision gets baked into a cached page, every visitor who hits the cache afterward sees whichever variant the first visitor got — the test silently stops splitting traffic the moment a cache plugin does its job. This is how the actual resolver avoids that, and the fallback chain it uses to assign a variant consistently without needing a database write for every anonymous visitor.

Page-render mode: send both, let the browser pick
For anything that can be cached — a product page render, the public REST response — the resolver’s page mode never removes a group to make the split. Both sides of an active test are kept and tagged with ab_test_id, ab_variant, and ab_split:
if ( $assignments === null ) {
$groups = $this->tag( $groups, $group_a, $test_id, 'a', $split );
$groups = $this->tag( $groups, $group_b, $test_id, 'b', $split );
continue;
}
The HTML that gets cached is identical for every visitor — both groups present, both tagged — and a small storefront script reads the tags client-side to hide one and show the other, writing a first-party cookie so the same visitor sees the same variant on their next page load. A cache doesn’t know or care that a test is running; it’s just caching one page that happens to contain both possibilities.
Visitor mode: four-step fallback to pick one
Add-to-cart validation is different — it needs to know which single variant this specific shopper actually saw, in order. visitorVariant() checks four sources, in a fixed priority order, stopping at the first one that resolves:
if ( isset( $assignments[ $test_id ] ) ) return $assignments[ $test_id ]; // 1. posted with the form
$cookie = self::cookieVariant( $test_id );
if ( $cookie !== null ) return $cookie; // 2. saue_ab_{id} cookie
$inferred = $this->inferFromSubmission(...);
if ( $inferred !== null ) return $inferred; // 3. which side's fields were submitted
return $this->sessionVariant( $test_id, $split ); // 4. deterministic hash, session-sticky
Steps 1–3 are all ways of recovering a variant the shopper’s own request already implies — an explicit assignment, a cookie the storefront script set, or simply noticing that only one side’s field IDs appear in what they submitted. Step 4 is the fallback for when none of that is available: a session with cookies disabled, a request with no prior context. That’s where the actual bucketing math lives.
The fallback: crc32, not random
sessionVariant() hashes a session-derived seed plus the test ID, buckets the result into 0–99, and compares against the test’s traffic split:
$seed = $this->getSessionSeed() . ':' . $test_id;
$bucket = abs( crc32( $seed ) ) % 100;
$variant = $bucket < $traffic_split ? 'a' : 'b';
A hash instead of rand() matters here specifically because it's deterministic: the same seed always produces the same bucket, so the very first computation for a given visitor and test already lands on the variant they'll keep seeing — no prior assignment needs to exist yet. It's then written into the WooCommerce session so a returning request in the same session reads the stored value directly rather than recomputing, but the recompute would give the identical answer either way.
What happens once a winner is declared
None of the splitting logic above runs once a test has a recorded winner — the resolver checks for that first and, if found, simply drops the losing group from the results entirely:
if ( $winner > 0 && in_array( $winner, [ $group_a, $group_b ], true ) ) {
$groups = $this->removeGroup( $groups, $winner === $group_a ? $group_b : $group_a );
continue;
}
A decided test costs nothing extra to serve afterward — no tagging, no cookie logic, no hashing — every visitor just gets the winning group as if it had always been the only one. It's the one state where Smart Add-Ons cache-safe A/B testing has nothing left to keep safe for the cache, because there's no longer a split to protect.
Common questions
Could a shopper manipulate their own crc32 bucket by changing their session?
Forcing a new WooCommerce session (clearing cookies, an incognito window) does reroll the bucket, the same way it would reset any session-scoped state. That's an accepted characteristic of session-based bucketing generally, not something specific to Smart Add-Ons cache-safe A/B testing, and it doesn't affect the aggregate split across real traffic.
Does Smart Add-Ons cache-safe A/B testing work with page caching plugins out of the box?
That's the entire point of the tag-both-groups design — the cached HTML is variant-neutral, so no cache-plugin-specific integration or cache-bypass rule is needed for the split itself to keep working. What does still need attention on a cached site is anything that fragments the page per visitor differently, which is a general page-caching consideration, not one unique to this feature. It's also why Smart Add-Ons cache-safe A/B testing needed its own resolver in the first place, rather than reusing generic split-testing logic built for uncached pages.
For the store-owner-facing walkthrough of setting up a test, see the A/B testing guide, and the PHP crc32() documentation (opens in new tab) for the hash function this bucketing is built on.
Comments