Split URL Tests
Run a URL-split test as a Fullstack A/B experience, and keep both arms counted symmetrically
In a web project, Split URL is a dedicated experience type: you give Convert two or more URLs and the
tracking script redirects the browser to the one the visitor was bucketed into.
Fullstack projects have no split_url experience type — see Requirements.
You get the same test by running a standard a/b_fullstack experience and doing the routing yourself,
in the code that handles the request. The SDK returns a variation; which page that variation serves is
your decision.
This page covers what changes when you move the split server-side, how to express URL targeting under
Fullstack's rule types, where to put the routing so both arms are counted symmetrically, and the
specific ways a server-side split can still produce a Sample Ratio Mismatch.
This is a rebuild, not a migration. A Fullstack experience lives in a Fullstack project with its
own entitykeys, its own audiences and its own goals. There is no import path from a web Split URL
experience and no report continuity — the new experience starts from zero. Plan it as a new test.
What Changes
| Web Split URL | Fullstack equivalent | |
|---|---|---|
| Experience type | split_url | a/b_fullstack |
| Who sends the visitor to the variant | Convert's tracking script, in the browser | your request handler, before the response is built |
| What a variation is | a destination URL | a variation key you map to a route, template or origin path |
| URL targeting | URL rules configured in the UI | a generic_text_key_value rule matched against a key you pass |
| When the visitor is counted | after the browser reaches the destination | at the runExperience call, before the response is sent |
| Conversions | auto-detected by the tracking script | you call trackConversion — see Tracking Conversions |
The counting row is the one that changes your numbers. Server-side, a visitor is counted the moment
you bucket them, so someone who abandons before the page finishes loading is still in the report. Totals
read slightly higher than the equivalent web Split URL test. That is expected, and because it applies
to every arm equally it does not skew the split.
Configure the Experience
Create an a/b_fullstack experience with one variation per destination. Every entity needs a unique
key — the variation keys are what your routing code switches on, so name them for the destination
(checkout-v2, pricing-simple) rather than variation-1.
Fullstack rules are limited to four types. URL targeting is expressed with generic_text_key_value,
matched against a key you pass in locationProperties:
| What you want to target | Rule type and matching | Key to pass |
|---|---|---|
| Everything under a path | generic_text_key_value + startsWith | path → /checkout |
| A URL containing a fragment | generic_text_key_value + contains | url |
| An exact page | generic_text_key_value + matches | path or url |
| A pattern across many pages | generic_text_key_value + regexMatches | path or url |
| A file extension or suffix | generic_text_key_value + endsWith | path |
| One hostname of several | generic_text_key_value + matches | hostname |
The key name in the rule and the key you pass must be identical — the SDK matches them verbatim and
does no normalizing. If your web rules were written against the full URL, pass the full URL; if they
were written against the path, pass the path. The complete key-by-key mapping for audiences, locations
and goals is in From the Tracking Script to the SDK.
Audiences in a Fullstack project should be transient — conditions are re-checked every time the
visitor encounters the experience. Sticky bucketing across sessions comes from a
Persistent DataStore, not from the audience.
Route in the Request Handler
Bucket once, at the top of the handler, then branch on the variation key. Both arms must come out of
the same runExperience call.
app.get('/checkout{/*splat}', async (req, res) => {
const variation = req.convertContext.runExperience('checkout-split', {
locationProperties: {
url: req.protocol + '://' + req.get('host') + req.originalUrl,
path: req.path,
hostname: req.hostname
}
});
// One branch per destination. Every arm returns through the same path below.
const key = variation && typeof variation !== 'string' ? variation.key : null;
const template = key === 'checkout-v2' ? 'checkout-v2' : 'checkout';
res.render(template, { visitorId: req.cookies['cv_vid'] });
});use OpenAPI\Client\BucketingAttributes;
Route::get('/checkout/{any?}', function (Request $request) {
// Both come from your visitor-id middleware — a closure captures nothing it
// does not list in use(...), so resolve them from the request.
$context = $request->attributes->get('convertContext');
$visitorId = $request->attributes->get('convertVid');
$variation = $context?->runExperience('checkout-split', new BucketingAttributes([
'locationProperties' => [
'url' => $request->fullUrl(),
// path() strips the leading slash — restore it so the value matches
// a rule configured as `/checkout`.
'path' => '/' . ltrim($request->path(), '/'),
'hostname' => $request->getHost(),
],
]));
$key = $variation?->variationKey;
$template = $key === 'checkout-v2' ? 'checkout-v2' : 'checkout';
return view($template, ['visitorId' => $visitorId]);
})->where('any', '.*');# app/controllers/checkout_controller.rb
def show
variation = @convert_context.run_experience("checkout-split", {
location_properties: {
url: request.original_url,
path: request.path,
hostname: request.host
}
})
# A miss returns a Sentinel, not nil, and its #key is nil — so one comparison
# handles hit and miss. Never call #dig on it; Sentinel has no such method.
template = variation.key == "checkout-v2" ? "show_v2" : "show"
render template
end@app.route("/checkout", defaults={"rest": ""})
@app.route("/checkout/<path:rest>")
def checkout(rest):
variation = g.convert_ctx.run_experience(
"checkout-split",
location_attributes={
"url": request.url,
"path": request.path,
"hostname": request.host,
},
)
key = variation.variation_key if variation else None
template = "checkout_v2.html" if key == "checkout-v2" else "checkout.html"
return render_template(template, visitor_id=g.convert_vid)Two shapes, and which to prefer
Serve different content under one URL. The visitor stays on /checkout and your handler decides
what to render or which origin to fetch. Prefer this. The browser URL never changes, so there is no
second URL for anyone to reach directly, no second cache key, and no navigation step between the
decision and the response.
At the CDN tier this is the transparent-rewrite shape in
Cloudflare Workers, Pattern 3.
Redirect to a genuinely different URL. Only do this when the URL itself is part of what you are
testing — a separate landing page, a different domain, a page you need indexed separately. Bucket and
count before you issue the redirect, so the visitor is recorded whether or not they arrive, and read
the direct-entry hazard below.
Keep Both Arms Symmetric
Moving the split server-side removes the browser from the counting path. A redirect the browser blocks,
a visitor who leaves mid-navigation, an extension or privacy setting that stops the hop — none of those
can cost you an exposure any more, because the exposure is recorded on the server before the response
is built.
It does not make Sample Ratio Mismatch impossible. Convert's SRM check reads the exposure counts per
variation; it does not know or care where the bucketing happened, so a Fullstack experience is checked
the same way a web one is. Note that the check is off by default — turn it on in the project's
statistics settings, or nothing will alert you to an asymmetric split. What changes is the list of
causes. These are the ones that belong to you now:
-
Bucket before you branch. One
runExperiencecall, above every conditional. The moment one arm
reaches a code path that buckets and another doesn't — an early return, a guard clause, a cached
fast path, an error handler that renders the control — that arm stops being counted. This is the most
common cause and the easiest to introduce during a refactor. -
Flush before the response ends. Short-lived runtimes (serverless functions, edge workers, CLI
jobs) can finish before the SDK's batch timer fires, dropping whatever is still queued. Call
releaseQueues/flushon every path that returns a response, not just the happy one. If one arm
returns early and skips the flush, that arm under-counts and the other does not. -
Cache or CDN in front of one arm. A cached response means your handler never runs, so nothing is
bucketed and nothing is counted — while the uncached arm keeps counting normally. If a bucketed route
sits behind a cache, either vary the cache key on your visitor-ID cookie or bypass the cache for that
route. -
Direct entry to one arm's URL. Only applies if you used genuinely distinct URLs. Search results,
bookmarks, ads, email links and internal links send people straight to one arm without passing through
the split, and that traffic is counted for that arm only. Either route transparently under a single
URL, or make the destination handler run the samerunExperiencecall so entry from any direction is
bucketed identically. -
Visitor-ID scope. You own the ID. If the cookie is set with a
pathordomainnarrower than
every URL in the test, one arm sees returning visitors as new ones and the split drifts. Set it once,
at the outermost path and domain that cover all arms, and read it everywhere — including on any
conversion fired later. See Server-Side Experimentation for the
identity patterns. -
Bots and crawlers. Convert's browser tracking screens known bots and crawlers out before they are
counted. Events arriving from an SDK are trusted as coming from your own server, so that screening
does not apply to them — every crawler that reaches your handler gets bucketed and counted. Crawlers
do not distribute themselves evenly across arms, so this shows up as skew. Filter obvious non-human
traffic (User-Agent, your CDN's bot score) before you callrunExperience, or pass the SDK's
tracking-disable option for those requests so they bucket without emitting an event.
A useful check before you launch: run the handler against both arms with tracking on and confirm you see
exactly one exposure event per request on each. If one arm is silent, you have found the SRM before your
visitors did.
Track Conversions
Conversions work the same as in any Fullstack experience — call trackConversion with the goal key
from wherever the conversion actually happens, using the same visitor ID that was bucketed. If the
conversion happens on a later request, or on a page the split routed to, the visitor ID cookie is what
links it back. Goal-by-goal recipes are in Tracking Conversions and
From the Tracking Script to the SDK.
See Also
- Server-Side Experimentation — visitor identity, bucketing, consent and conversion patterns
- From the Tracking Script to the SDK — UI concepts mapped to SDK calls
- Requirements — Fullstack rule, experience and audience constraints
- Running Experiences —
runExperienceparameters and return values - Persistent DataStore — sticky bucketing across sessions
Updated 1 day ago