Self-hosted split testing, built for engineers

A/B testing that lives on your servers.

No third-party script shipping your visitors’ data to someone else’s cloud, no consent-dialog scramble, no client-side flicker while a snippet decides what to render. Split runs on your own infrastructure — and its UCB bandit stops wasting half your traffic on variants that are already losing.

YAML config WebSocket assignment UCB-optimized traffic

How it works

A copy test, from YAML to optimized traffic — no deploy required

Split runs as a self-hosted Phoenix API on your own infrastructure. You define tests in config, connect over WebSockets or HTTP, and the bandit algorithm handles traffic allocation from there.

Write a YAML test config

Define a target URL and one or more variants. Each variant lists CSS selectors and the DOM mutation to apply — replace, innerHTML, or remove. Upload it via POST /api/tests. No codebase changes, no release to wait on.

Connect over WebSocket

Clients connect with a user_id (persisted in a 30-day cookie) and the current url. Split auto-assigns and returns variants for every matching test on join — no separate lookup call, no third-party script, no round trip to another company’s cloud.

Apply mutations, track conversions

The client applies the returned DOM mutations directly. Conversions are tracked via dom_event, url, time_on_page, time_on_site, or scroll_depth — whichever fits the test.

UCB shifts traffic automatically

Every impression and conversion updates each variant’s upper-confidence bound. Instead of holding a fixed split for the life of the test, Split keeps moving traffic toward whichever variant is most likely to win — so losing variants stop costing you conversions early.

Features

Built for teams that outgrew marketing suites and aren’t ready for enterprise contracts

No dashboard fluff, no per-visitor pricing tier, no sales call. Split is a focused Elixir/Phoenix service you run yourself, with a small, well-defined API surface.

YAML configuration

Define tests with CSS-selector-based DOM mutations — replace, innerHTML, or remove. Version them like any other config file, and ship a copy test without a code review.

WebSocket API

Real-time variant assignment and conversion tracking over Phoenix Channels, served from your own deployment. Join once, get variants, push conversions — nothing leaves your infrastructure.

Auto-assignment by URL

Connect with the current page URL and Split returns variants for every matching test instantly — exact paths, wildcards, or query params.

UCB bandit algorithm

Upper Confidence Bound selection balances exploration and exploitation automatically, so traffic keeps shifting away from underperforming variants instead of sitting on a fixed split for the whole test.

30-day persistence

Variant assignment stays consistent for a user via a cookie-backed user_id, so returning visitors keep seeing the same variant.

RESTful HTTP API

Bearer-token-authenticated endpoints to upload, list, inspect, and delete tests, plus per-test statistics — self-hosted, so you own the data behind every one of them.

The API surface

A headline test, without a pull request

Upload a YAML test to your own API, then let clients auto-assign over a WebSocket you control. This is the actual shape of both — no vendor snippet in between.

test.yaml POST /api/tests
test_id: homepage-hero-v1
url: https://example.com

variants:
  variant_a:
    "#hero":
      mutation: replace
      content_type: html
      content: |
        <h1>Turn data into revenue</h1>
        <p>Split testing built for engineers.</p>

    "#hero-cta":
      mutation: innerHTML
      content_type: html
      content: |
        <button class="btn-primary">Start Free Trial</button>

conversion:
  type: dom_event
  target: "#hero-cta"
  event_name: click
client.js wss://.../socket
import { Socket } from "phoenix";

const socket = new Socket("wss://example.com/socket", {
  params: { user_id: getUserId(), url: location.href },
});
socket.connect();

const lobby = socket.channel("s", {});
lobby.join().receive("ok", (resp) => {
  // resp.tests already contains this visitor's
  // variant assignment for every matching test
  for (const test of resp.tests ?? []) {
    if (!test.is_control) applyMutations(test.mutations);
  }
});

// On the CTA click, report the conversion
document.querySelector("#hero-cta").addEventListener("click", () => {
  lobby.push("track_conversion", { test_id: "homepage-hero-v1" });
});

Algorithm

Stop funding losing variants

A fixed 50/50 split keeps sending traffic to the losing variant for as long as the test runs — that’s the cost of waiting for statistical significance. Split calculates a 95% upper confidence bound for each variant’s conversion rate (a Wilson score interval) and continuously routes traffic to whichever variant currently has the highest bound, so underperformers lose share as soon as the data says so.

Cold start

With zero data, a variant's upper bound starts near 0.87 — maximum uncertainty means new variants get traffic first, without any warm-up period to configure.

Deterministic, not sampled

Unlike Thompson sampling, there's no randomness at decision time — the same inputs always produce the same variant choice, which makes behavior easy to reason about and debug.

Traditional A/B Split (UCB)
Traffic split Fixed 50/50 Shifts to the leading variant automatically
New variants Same traffic share as control Get priority first, due to higher uncertainty
Underperformers Keep receiving traffic until the test ends Lose traffic as soon as their upper bound falls behind
Decision process Manual significance check Continuous — bounds are recalculated on every impression

FAQ

Frequently asked questions

Is Split actually self-hosted?

Yes. Split is an Elixir/Phoenix API you run on your own infrastructure — `mix phx.server` and it’s up, with your own API key set via `SPLIT_API_KEY`. There’s no third-party script embedded in your pages and no visitor data sent to a vendor’s cloud, so there’s no consent dialog to add just for the test.

What does Split actually run on?

Split is an Elixir/Phoenix API. Tests are uploaded as YAML, variant assignment happens over Phoenix Channels (WebSockets), and management runs through a RESTful HTTP API secured with Bearer token auth.

Do I need a deploy to ship a copy test?

No. A test is a YAML file — a target URL, one or more variants, and the CSS selectors they mutate — uploaded via POST /api/tests. Changing a headline or a CTA is a config upload, not a pull request.

How does a page get a variant?

A client connects to the WebSocket endpoint with a user_id and the current url. On join, Split matches that URL against every active test — exact path, wildcard (*), or recursive wildcard (**) — and returns variant assignments and DOM mutations automatically.

What can a variant actually change?

Each variant is a map of CSS selectors to mutations: replace (swap the outerHTML), innerHTML (swap the contents), or remove. Content can be HTML, Markdown, or plain text.

How is a visitor kept on the same variant?

The client persists user_id in a 30-day cookie and sends it on every connection. Returning visitors within that window get the same variant they saw before — no re-randomization.

How does the traffic-allocation algorithm work?

Split uses an Upper Confidence Bound approach: it computes a 95% Wilson score upper bound on each variant’s conversion rate and always routes to the variant with the highest bound. New variants get priority from uncertainty, and traffic shifts away from underperformers automatically as bounds cross — instead of holding a fixed 50/50 split for the life of the test.

What conversion types are supported?

dom_event (a click or other DOM event on a selector), url (navigating to a target path), time_on_page / time_on_site (a duration threshold), and scroll_depth (a scroll percentage threshold).

Can I handle single-page app navigation?

Yes. Push a url_changed message over the existing socket connection with the new URL, and Split returns fresh variant assignments in the same shape as the initial join response — no reconnect required.

Run split tests on your own servers, without funding losers.

Write a YAML config, connect a socket, and let Split’s UCB algorithm find your winner — no vendor script, no consent dialog, no deploy.

Early access

Get early access to self-hosted split testing

Tell us a bit about what you're building — especially if you're EU-based, privacy-conscious, or migrating off Google Optimize — and we'll reach out when Split is ready for you.