On a traditional multi-page site, every navigation is a full page
load. The snippet starts from scratch every time. Easy.
On a single-page app, navigations happen client-side. The URL changes,
the DOM is rewritten, but the page never reloads. The snippet has to
notice, tear down any variant that no longer applies, evaluate
targeting against the new route, and apply the variants that do.
The flow
Every time the URL changes, in order:
- Re-evaluate targeting. For each experiment, check Location
and Audience against the new URL. Audience match is session
sticky, Location re-evaluates on every route. Mid-session
bucketing runs the deterministic hash, a visitor who was in
Variant B on route one is still in Variant B on route two. This
settles which experiments are eligible here, which the cleanup
phase needs in order to know what to strip.
- Cleanup phase. Run
onCleanup handlers for every experiment
that was active on the previous route, in registration order,
including the ones still eligible here. Then, for the experiments
that are no longer eligible, remove their
<style data-abtestly-exp="..."> tags. The activation triggers
armed on the previous route are disconnected in the same pass.
- Impressions. One batched
/e beacon covering every eligible
experiment, fired before any variant DOM work so a variant that
throws can never under-count its own arm.
- CSS re-inject. For experiments that apply here, stamp their
<style> blocks again.
- Apply. Run
onApply handlers with an ApplyContext
parameterized with the new route. Variant JavaScript does not
re-run, only the registered callbacks do.
- Re-eval page-visit goals. A page-visit goal for
/checkout
fires when the visitor navigates to /checkout, whether that
navigation was a full load or a client-side push.
- Re-arm triggered activations. Click and view triggers are
re-installed for eligible experiments on the new route.
- Dispatch
abtestly:routechange, last, so its detail carries
the final active and deactivated lists.
The whole sequence runs in one synchronous pass, so the visitor
never sees a half-applied page. For pushState and replaceState
the pass is deferred by a single microtask, just long enough for
location.href to reflect the new URL; popstate and hashchange
run it directly. An async onApply callback is the one thing that
outlives the pass, which is what routeRevision is for.
What triggers a route change
history.pushState, wrapped.
history.replaceState, wrapped.
popstate, listened to.
hashchange, listened to.
Wrapping is done once per page (window.__abtestlySpaWrapped guards
against double-wrapping if you accidentally load the snippet twice).
Same-URL pushes are deduped, a router that calls pushState(null, '', location.href) does not trigger a full cleanup and reapply.
The ApplyContext
Every onApply callback receives an ApplyContext:
There is no experimentKey on the context. For the short key, use
the ABTESTLY_EXP_KEY binding, see Runtime
bindings.
routeRevision, detecting supersession
If your onApply does async work (a fetch, a waitFor), by the time
it completes the visitor might have navigated away. Applying stale
work to the new page would be a bug.
The runtime logs a spa_stale_cancelled event when this check
fires, visible in the dev debugger so you
can see supersessions happen in the wild.
Cleanup
onCleanup runs on a route change, and only on a route change. It
fires for every experiment that was active on the previous route,
including the ones still eligible on the new one, because those are
about to have their onApply callbacks fire again and cleaning up
first is what stops listeners and injected nodes stacking. Treat a
cleanup as “this is about to be re-applied”, not as “this experiment
is over”.
Nothing else tears an experiment down. There is no cleanup on tab
close or pagehide, and pausing, archiving or unpublishing an
experiment takes effect on the visitor’s next full page load rather
than mid-session.
Handlers run in registration order, the first cleanup you
registered is the first to run.
CSS is auto-torn-down
The <style data-abtestly-exp="<id>"> block holding a variant’s CSS
field is removed when the experiment stops applying, meaning it was
eligible on the previous route and is not eligible on this one. An
experiment that stays eligible keeps its styling, the tag is simply
stamped again. Either way you do not write a cleanup for a CSS-only
variant.
That tag is the only thing the runtime undoes for you. JavaScript
changes need their own cleanup because the runtime has no way to
know what you did.
The route-change event
The runtime dispatches an abtestly:routechange event on window
after re-evaluation completes:
Use it for global cross-experiment coordination, logging, warming
a cache, that does not fit inside any single variant.
Turning it on
Site → Settings → SPA support → Enable. See
SPA panel. Publish-gated.
Once on, everything above is automatic. Your variants opt into SPA
awareness by using onApply and onCleanup instead of running
naked code at the top level. Last modified on August 1, 2026