Skip to main content
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:
  1. 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.
  2. 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.
  3. 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.
  4. CSS re-inject. For experiments that apply here, stamp their <style> blocks again.
  5. Apply. Run onApply handlers with an ApplyContext parameterized with the new route. Variant JavaScript does not re-run, only the registered callbacks do.
  6. 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.
  7. Re-arm triggered activations. Click and view triggers are re-installed for eligible experiments on the new route.
  8. 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