Client-Side Experiments
The Apex snippet runs A/B tests directly in the browser. It fetches your active experiments, assigns each visitor to a variant deterministically, and applies DOM changes — all before the page becomes visible.
How Experiments Are Fetched
On every page load, the snippet calls the experiments endpoint:
/api/experiments/active?key=PROJECT_KEYReturns all active experiments for the project
The response is an array of experiment objects, each containing variant definitions, traffic split, target URL, and conversion goal configuration. Results are cached in memory for 5 minutes to minimize network requests across page navigations.
Variant Assignment
Each visitor is assigned to a variant using deterministic hashing. The snippet computes a MurmurHash of the visitor ID concatenated with the experiment ID:
hash = murmurhash3(visitorId + experimentId)
bucket = hash % 100
variant = bucket < trafficSplit ? "control" : "variant_b"
This guarantees the same visitor always sees the same variant — no server-side state required. The assignment is stable across sessions, devices (same browser), and page reloads.
Info
The trafficSplit value represents the percentage of traffic that sees the control. A 50/50 split means trafficSplit = 50.
Page Targeting
Experiments can be restricted to specific pages via the targetUrl field. The snippet normalizes both the experiment URL and the current page URL (stripping www., trailing slashes, and protocol) before comparing. If no targetUrl is set, the experiment runs on every page.
Variant Types
Text Changes
The most common variant type. The snippet finds a DOM element by CSS selector and replaces its text content.
{
"type": "text",
"selector": "h1.hero-title",
"originalValue": "Grow your business",
"value": "Scale faster with Apex"
}
If the selector doesn't match, the snippet falls back to text-based matching — it scans heading, paragraph, button, and link elements for content matching originalValue. This makes experiments resilient to minor markup changes.
Redirect Variants
Instead of modifying the DOM, redirect variants send the visitor to a different URL entirely.
{
"redirectUrl": "https://example.com/landing-v2"
}
Redirects happen immediately after variant assignment, before any other DOM processing.
Hide / Show
Toggle element visibility. Useful for testing whether removing or adding a section improves conversions.
{ "type": "hide", "selector": ".testimonials-section" }
{ "type": "show", "selector": ".pricing-cta", "value": "flex" }
HTML and Image Swaps
Replace inner HTML or swap image sources for richer variant changes.
{ "type": "html", "selector": ".cta-block", "value": "<a href='/start' class='btn'>Get Started Free</a>" }
{ "type": "image", "selector": ".hero-image", "value": "https://cdn.example.com/hero-v2.jpg" }
Warning
HTML variants are sanitized — script, iframe, object, and embed tags are automatically stripped to prevent XSS.
Structural Primitives — Move, Pin, Clone, Swap
These primitives let an experiment rearrange the page rather than just rewriting it in place. Use them when you suspect the position of an element drives conversions — pushing a CTA above the fold, pinning a sticky offer to the viewport, swapping the order of two cards.
The runtime declares its support via window.__APEX_RUNTIME__.supports. Older cached snippets without structural support skip these change types silently and the visitor sees the control variant — no broken pages.
Info
Authoring scope. pin is exposed in the visual editor as a one-click action on any hovered element. move, clone, and swap are runtime-only primitives at present — emit them from a server-side experiment, the SDK, or a manually-authored variant payload. A drag-and-drop visual editor for these is a future roadmap item.
Move — relocate an element
{
"type": "move",
"selector": "#download-cta",
"anchor": "#hero h1",
"anchorOriginalValue": "Free Play Tournaments",
"position": "before",
"originalParent": "section.hero-body",
"originalIndex": 3
}
| Field | Required | Description |
|---|---|---|
anchor | Yes | CSS selector for the element the move is positioned relative to |
position | Yes | before | after | firstChild | lastChild |
anchorOriginalValue | No | Visible text fallback if the anchor selector breaks |
originalParent, originalIndex | No | Source position metadata captured at edit time, used for undo |
Pin — stick an element to the viewport edge
{
"type": "pin",
"selector": "#site-nav",
"pinEdge": "top",
"pinOffset": 0
}
| Field | Required | Description |
|---|---|---|
pinEdge | Yes | top or bottom |
pinOffset | No | Pixels from the edge (default 0, max 80) |
Warning
The runtime rejects pinning any element whose measured height exceeds 25% of the viewport height. Large pinned overlays are a UI-redress risk; pin small CTAs and bars only.
Clone — duplicate an element near another
The original stays in place; a sanitized copy (with id stripped and data-apex-clone-of="<source-selector>" tagged) is inserted near the anchor.
{
"type": "clone",
"selector": ".cta-button",
"anchor": "#hero",
"position": "lastChild"
}
Swap — exchange two elements' positions
{
"type": "swap",
"selector": "#headline",
"anchor": "#subheadline"
}
Cross-parent swap is supported via an internal comment marker, so swapping elements that live in different containers stays correct.
Apply Order
When a variant carries multiple changes, the runtime applies them in two-key sort order:
- Tier: structural (
move/swap/clone/pin) → content (text/html/image/style) → visibility (hide/show) - Ordinal-within-tier: ascending
change.ordinal, falling back to original array index when missing
This guarantees that a text change targeting an element previously moved by a move change still resolves the right node. The validator rejects ambiguous multi-move graphs (two structural ops whose source/anchor selectors overlap) with the error ambiguous_multi_move.
Validator Contract
Every change is validated at the API boundary before persistence. Caps and rules:
| Field | Rule |
|---|---|
selector, anchor, originalParent | Length ≤ 512, no bare * for non-text types, deny patterns: :has(, :host(, :where(, :is( |
value | Length ≤ 8192 |
position | Enum: before | after | firstChild | lastChild |
pinEdge | Enum: top | bottom |
pinOffset | Number, 0 ≤ n ≤ 80 |
changeId | UUID v4 (assigned by the editor at capture time) |
| Per variant | Up to 50 changes |
Rejections return a typed error with a per-change field path so the editor can surface the exact problem inline (e.g. variants.variant_b.changes[2].anchor: missing_field).
Graduation Detection
When an experiment is promoted (winner shipped to 100% traffic), the snippet probes the live page on each load to see if the winning state is now native:
- Move / swap: source element is at the expected DOM position relative to anchor (sibling adjacency check)
- Pin: computed
position: fixedmatches with a 4 px offset tolerance - Clone: always fails closed — Apex can't distinguish a runtime-injected clone from a duplicate the merchant shipped, so clone-bearing experiments require manual graduation
- Text / image / hide / show: existing rules unchanged
When all changes test as native, the snippet sends a graduated beacon and stops applying the experiment on subsequent loads.
Per-Change Diagnostic Beacons
Each DOMChange carries a stable changeId UUID assigned at editor capture time. When the runtime applies a change, it can emit a per-change rearrange.change_applied beacon with that ID. Beacons are descriptive, not causal — they answer "did this specific change apply on this visitor's page?", not "which change drove the lift?".
Warning
When a variant arm bundles multiple changes (e.g. move CTA above the fold + change CTA copy), the changes are perfectly confounded. Apex measures variant lift correctly, but cannot statistically separate which change drove it — that requires a factorial design or running the changes as separate experiments. The dashboard surfaces this with a "Bundled changes" warning when both structural and content edits live in one variant.
Anti-Flicker Mechanism
The recommended install includes an inline <style id="apex-antiflicker"> tag that hides <html> synchronously during HTML parse, before the body ever paints. The async snippet then runs, applies experiment changes, and removes the inline style — so visitors never see the original content flash.
The snippet uses a localStorage cache (5-minute TTL) to decide whether to keep the page hidden:
- No matching experiment in cache → stub removed immediately, page renders at full speed
- Cached experiment matches current page → cached variant applied optimistically, then stub removed
- No cache yet (first visit) → keep stub up while we fetch, with a 1-second safety ceiling so a degraded API never strands visitors on a blank page
If the inline stub isn't present (legacy defer-only installs), the snippet falls back to setting opacity = "0" from JavaScript — best-effort, but on defer the body has typically already painted by the time the script runs, so the first-paint flicker can't be fully prevented. See Installation for the recommended two-line install.
MutationObserver Guard
For text changes, the snippet attaches a MutationObserver to the modified element. If a client-side framework (React, Vue, etc.) re-renders and overwrites the variant text, the observer re-applies it. The guard auto-disconnects after 5 seconds to avoid long-term overhead.
For structural changes (move, pin, clone, swap), the runtime keeps a bounded observer scoped to the source's expected parent that re-applies the change when SPAs re-mount the subtree. The bounds prevent perf and infinite-loop issues:
- Max 3 re-applies per change per page lifetime
- 250 ms debounce between re-apply attempts
- Churn detection: 4+ attempts inside a 2-second sliding window = abandon, emit a
rearrange.churn_abandonedbeacon, and stop fighting the host framework - 30-second hard ceiling on observer lifetime regardless of cap
Clones are fire-and-forget — once inserted near the anchor, they aren't re-applied if the subtree re-mounts.
Preview Mode
Test any variant without affecting live traffic by adding query parameters to your URL:
https://yoursite.com/landing?_apex_preview=variant_b&_apex_exp=exp-123
| Parameter | Required | Description |
|---|---|---|
_apex_preview | Yes | The variant ID to force (e.g., variant_b) |
_apex_exp | No | Limit preview to a specific experiment ID |
In preview mode, a green banner appears at the top of the page confirming which variant you're viewing. Preview visits are not counted in experiment results.
Tip
Use preview mode to QA your experiments before activating them. Share preview links with your team for design review.
Conversion Goals
Experiments can define a conversion goal that the snippet automatically tracks. Supported goal types:
- Click — Fires when a visitor clicks an element matching the goal's CSS selector
- Pageview — Fires when the visitor reaches a specific URL path
- File download — Fires when a visitor clicks a link to a file with a matching extension (e.g.,
.pdf,.zip) - Engaged time — Fires when a visitor is actively engaged for a threshold number of seconds (idle time and hidden tabs are excluded)
Goal conversions are sent as goal_conversion events with the experiment and variant attached. See Custom Events for the event payload format.
Next Steps
- Form Tracking — automatic form interception and hidden field injection
- Custom Events — send events manually via
window.apex.track() - Quickstart — end-to-end setup guide