pex

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:

GET/api/experiments/active?key=PROJECT_KEY

Returns 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
}
FieldRequiredDescription
anchorYesCSS selector for the element the move is positioned relative to
positionYesbefore | after | firstChild | lastChild
anchorOriginalValueNoVisible text fallback if the anchor selector breaks
originalParent, originalIndexNoSource 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
}
FieldRequiredDescription
pinEdgeYestop or bottom
pinOffsetNoPixels 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:

  1. Tier: structural (move/swap/clone/pin) → content (text/html/image/style) → visibility (hide/show)
  2. 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:

FieldRule
selector, anchor, originalParentLength ≤ 512, no bare * for non-text types, deny patterns: :has(, :host(, :where(, :is(
valueLength ≤ 8192
positionEnum: before | after | firstChild | lastChild
pinEdgeEnum: top | bottom
pinOffsetNumber, 0 ≤ n ≤ 80
changeIdUUID v4 (assigned by the editor at capture time)
Per variantUp 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: fixed matches 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_abandoned beacon, 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
ParameterRequiredDescription
_apex_previewYesThe variant ID to force (e.g., variant_b)
_apex_expNoLimit 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