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:
- Cleanup phase. For each experiment currently active whose
Location no longer matches the new URL, run its
onCleanup handlers (LIFO), disconnect any observers, remove
<style data-abtestly-exp="..."> tags.
- Re-evaluate targeting. For each experiment, check Location
and Audience against the new URL. 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.
- CSS re-inject. For experiments that now apply, inject their
<style> blocks.
- Apply. Run
onApply handlers with an ApplyContext
parameterized with the new route.
- 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.
Steps 1–6 are wrapped in a single micro-task after the URL change,
so the visitor sees no intermediate state.
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:
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 when the experiment is torn down, either because
the visitor navigated to a route the experiment does not match, or
because the experiment was un-published.
Cleanups run in LIFO order, the last cleanup you registered is
the first to run. Matches JavaScript’s stack semantics; also matches
what most code expects intuitively.
CSS is auto-torn-down
Any <style> block the runtime injected with a
data-abtestly-exp="<id>" attribute is auto-removed on cleanup. You
do not have to write a cleanup for CSS-only variants, the runtime
handles it.
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