The browser runtime should explain itself.
The ABTestly snippet is served from Cloudflare's edge network, then buckets each visitor and applies the variation in their browser. It reapplies cleanly across single page app route changes, records every accepted event, and writes those events to a canonical ledger that your final numbers come from.
Edge served, applied in the browser
ABTestly serves its experiment configuration from the edge and runs everything else in the browser. The experiment config is delivered from Cloudflare's network, served across more than 300 cities, with no origin round trip. The snippet itself is about 31 KB gzipped with a loader under 1.5 KB. What actually decides a variant and changes the page happens in the visitor's browser: the runtime reads the config, buckets the visitor, and applies the variation locally. Nothing about a visitor's assignment waits on your server.
The runtime pipeline
An ABTestly experiment moves through the same six stages on every page, and each one is named in the public docs rather than hidden in a black box.
- Config. The experiment config is delivered from the edge.
- Assign. A deterministic, sticky split places the visitor in a variant.
- Apply. The variant's CSS and JavaScript run in the browser.
- Measure. Every accepted event is recorded.
- Ledger. Accepted events land in the unsampled canonical store.
- Verdict. The results page computes the decision against that store.
Deterministic, sticky bucketing
Bucketing is the algorithm that turns a visitor into a variant. It is deterministic, the same inputs produce the same output every time, and it uses no shared state, so every visitor's bucket is computed from their stable id alone. ABTestly follows a two step model: first decide whether the visitor enters the experiment, then decide which variant they get.
- The hash. MurmurHash3, well known, fast, and uniformly distributed for short strings. Two different suffixes separate the allocation decision from the variant decision, so raising allocation does not shuffle who already sits in which variant.
- A per experiment salt. Every experiment gets its own immutable salt at creation, which breaks the correlation where a visitor would otherwise always land in the same slot across tests. Cloning an experiment generates a new salt on purpose.
- A stable visitor id. A UUIDv7, the time ordered identifier standardised in RFC 9562, written to localStorage and a cookie on first visit, resolved to the registrable domain so subdomains share one id.
The result is that a visitor sees the same variant whether they land on the tested page or navigate to it later in the session, and the same variant every time they return. The full method is public in the bucketing docs.
Applying variations
On the initial page load, the snippet boots, evaluates targeting and audiences for each experiment, and for every eligible one computes a bucket, fires an impression beacon, injects the variant's CSS into the head, and runs the variant's JavaScript once. Registered apply callbacks then fire. This is code first: the variation is the JavaScript and CSS you authored, running in place. For how those variations are written, read code first A/B testing with JavaScript and CSS.
What a visitor sees before the variation applies
ABTestly does not hide your page while the runtime loads. That is a decision, not a gap, and it has a cost worth stating plainly. The install snippet injects the runtime as a dynamic script, which does not block the parser, so the browser paints the page your server sent and the variation lands when the runtime arrives and runs. On a test that changes content above the fold, a visitor whose browser has not already cached the runtime can see the original first.
Hiding the page is the usual answer, and the reason we do not do it by default is that hiding works by delaying paint. An anti flicker overlay covers the page until the variation applies, which pushes every visitor's largest contentful paint later by however long the runtime takes to arrive, including the visitors who were never bucketed into any experiment. ABTestly's own speed guardrail flags a variant as slower when its p75 largest contentful paint sits 400 ms or more above control. A default that hid every page on every load would ship the regression the guardrail exists to catch.
So here is how long the gap actually is. Measured on 20 September 2026 over 200 page loads in each condition, headless Chromium against the real built runtime, served gzipped under the same cache headers production sends, with one variation that rewrites a heading above the fold. Each figure is the 75th percentile of the time from navigation start to the moment that heading changed in the DOM.
- A repeat view, with the runtime already in the browser cache: 19.9 ms. The runtime executes while the browser is still parsing the page. On 199 of 200 loads no painted frame showed the original heading; on the remaining load it was there for one frame.
- A first visit with an empty cache, on a connection throttled to 1.6 Mbps down and 150 ms round trip: 529 ms. Here the original was on screen first on all 200 loads, for a median of 326 ms, because the page paints while the runtime is still downloading.
- A single page app route change: 2.2 ms. The runtime wraps
history.pushStateand schedules its route handler as a microtask, which runs before the browser paints the frame the navigation belongs to. No painted frame showed the original route's content on any of the 200 route changes.
Read the first visit figure as a floor rather than as field data. The measurement runs against a local origin, so it carries the throttle and the transfer but none of the DNS, TLS or edge latency a real first visit also pays, and it throttles the network without throttling the processor. A first visit on a mid range phone is slower than 529 ms, not faster.
Writing a variation that does not need the page hidden
When the runtime arrives is a property of the visitor's connection and cache. Whether they notice is mostly a property of what you wrote.
- Put the change in CSS wherever it will go. Variant CSS is injected into the head as a style element and applies to elements the browser has already parsed. A colour, a size, a hidden element or a reordered row swaps without redrawing text the visitor was in the middle of reading.
- Move the test below the fold when the hypothesis allows it. A headline swap at the very top of the page is the most visible case there is. The same idea tested on a section the visitor has to scroll to is not something they can catch.
- Do not hide the element yourself. Hiding a container in variant CSS and revealing it from variant JavaScript reproduces the overlay's cost in one corner of the page, and if that JavaScript throws, the element never comes back. This is why the code check flags variation CSS that hides
htmlorbody. - Use
waitForfor anything your framework renders late, rather than a hand rolled interval. It polls every 50 ms, fires once when the target appears, and gives up silently after a timeout that defaults to 5000 ms, so a selector that never matches cannot leave a timer running on the visitor's page. - Register
onApplyif the site is a single page app. Variant JavaScript runs once per page load; the runtime calls your registered callbacks again on every route where the experiment is active. A variation that only mutates the DOM at the top level changes the route it loaded on and nothing after it.
If you do need the page held until the variation lands, the install card in your dashboard has an Add anti flicker checkbox, unchecked by default. Ticking it adds a style rule that sets the page to zero opacity and a timer that reveals it after 2 seconds whether or not the runtime ever arrived. In the same measurement, ticking it held the page for a 75th percentile of 21 ms on a repeat view and 532 ms on the throttled first visit, and it does that on every load for every visitor, including the ones in no experiment. It is worth turning on for a specific test that needs it, and worth turning off again afterwards.
Single page apps: cleanup, then reapply
If your site swaps the page in JavaScript instead of doing a full browser navigation, ABTestly supports it natively. With SPA support on, the snippet wraps history.pushState and history.replaceState, listens for back and forward navigation and hash changes, and runs a lifecycle on every detected route change. The order is the whole idea, cleanup, then impression, then apply:
- Cleanup first. Every cleanup callback for experiments that were active on the previous route fires, including those still active on the new route, so they reapply with no double bound listeners or duplicated styles. CSS the editor injected is removed automatically for truly deactivated experiments.
- Impressions before variant code. Targeting is evaluated again against the new URL and impressions fire for every eligible experiment in a first pass, before any variant DOM work, so a broken variant on one arm can never undercount impressions on that arm. That is how the runtime prevents a sample ratio mismatch from a silently undercounted arm.
- Reapply. CSS is injected again and apply callbacks fire again for every eligible experiment. Variant JavaScript does not run again, only the registered callbacks do, which stops callback registrations stacking on every navigation.
The three helpers make this reliable. onApply runs each time an experiment becomes active on the current route, onCleanup runs when the visitor leaves a tested route and just before it reapplies, and waitFor handles elements that render asynchronously by polling every 50 ms and firing once when the target appears, giving up silently after a timeout that defaults to 5000 ms. Every callback and the route handler itself run inside their own try and catch, so a mistake in one experiment cannot break the page or another experiment. The full reference is the single page apps docs.
Event capture
The runtime records two kinds of event, exposures and goal fires, each carrying its full context: the variant key, and for revenue goals the order id and currency. Goals can fire declaratively from the dashboard or from your own code with abtestly.trackGoal(key, valueOrOpts), where an order id enables server side revenue deduplication so a retried beacon does not double count. On a route change, impressions batch into a single beacon per route, and page visit goals evaluate again against the new URL while listener goals stay live from initial load. Sensitive fields are redacted, and a variant error is reported back on first occurrence per session so a variant that breaks on a real page gets caught early.
The exact experiment ledger
Every event that arrives is written to two places. Cloudflare Analytics Engine is fast and cheap to query and drives the live counters on the dashboard. The exact experiment ledger is the canonical store your final numbers come from. Analytics Engine is optimized for a huge write rate and can sample its own data under very heavy bursts, which is Cloudflare's documented behaviour rather than a worry of ours: at very high volumes it downsamples to maintain performance, and marks the rate on each row, which is fine for a live counter and less fine for a report. The ledger is optimized for correctness: every accepted event, in order, retrievable.
- Trustworthy verdicts. The results page reads the ledger for its numbers, so a winner is only called against the complete accepted set.
- Raw event export. The export reads the ledger's archives, giving you the same rows it held.
- Audits. If a compliance team needs every event an experiment saw, the ledger is the source of truth.
The ledger keeps two things on different clocks. The per visitor records your results are computed from persist for the life of the experiment, so a number you read today is the same number you read a year ago. The raw individual events behind them are archived for 24 months and then purged nightly, which is what raw event export and audits read from. Both are stated in the privacy policy. For any experiment you have run, the ledger contains every exposure and every goal that was accepted, and the statistics are computed against that set, with no sampling step on any surface you make decisions from. See the exact ledger docs.
Debug it on the page
When something looks off, open devtools and call window.__abtestly.debug() right on the page. It tells you whether this visitor was even in the test, whether the change actually ran, and whether the result got tracked. The runtime object is a stable public surface you can call from variant JavaScript or your own scripts, documented in the window.abtestly API reference.
Quick answers
Does the experiment run at the edge?
The config is served from Cloudflare's edge network, but the bucketing and the variation apply both happen in the visitor's browser. The edge delivers the config with no origin round trip. The browser decides the variant and changes the page.
Will a visitor always see the same variant?
Yes. Bucketing is deterministic and sticky. It is computed from a per experiment salt and a stable visitor id, so the same visitor gets the same variant whether they land on the tested page or navigate to it later, and every time they return, unless they clear their cookies and localStorage.
Does the page flicker before a variation applies?
It can, and ABTestly does not hide the page by default to prevent it. The runtime is injected as a dynamic script that does not block the parser, so on a first visit the browser paints the original and the variation lands when the runtime arrives. Measured on 20 September 2026 over 200 page loads per condition, the 75th percentile from navigation start to the variation applying was 19.9 ms with the runtime already cached, 529 ms on a first visit over a connection throttled to 1.6 Mbps down and 150 ms round trip, and 2.2 ms on a single page app route change. Hiding the page is a checkbox on your install snippet, unchecked by default, because hiding works by delaying paint for every visitor including the ones in no experiment.
How does it stay correct on a single page app?
On every route change the runtime cleans up, then records impressions before any variant code, then reapplies. Recording impressions first means a broken variant on one arm cannot undercount that arm, which protects against a sample ratio mismatch.
Are the results sampled?
No. Live dashboard counters read Analytics Engine, which can sample under heavy bursts, but every accepted event is also written to the exact experiment ledger, and the verdict is computed against that complete set with no sampling.
Behavior on this page reflects current ABTestly developer documentation. The public docs name the methods, the event path, the statistics, and the operational limits.