Why your Optimizely experiment won't fire on SPA route changes
It works on a hard page load. Then a real visitor clicks deeper into the app, the URL changes without a full reload, and the experiment quietly vanishes. The variation stops applying. Nothing buckets. Refresh that same page on its own and it all works again, which is exactly how it sails through QA. That split, fine on refresh and dead during a real journey, is the whole tell.
The symptom
You build the test, load the page fresh, and it behaves perfectly. The variation renders, activation fires, the visitor buckets. Then someone lands on a category page, taps into a product, and the app swaps the view without reloading the document. The experiment is now gone. No variation on screen, and nothing in analytics for that view.
Why does QA miss it? Because you test by opening URLs directly, and real people get there by clicking. So the bug sits quietly for weeks on a big content site, starving the variant of traffic and bending the split while everyone assumes the test is running fine.
Why single page apps break activation on load
A single page app moves between views with the History API, pushState and
replaceState, and rebuilds the DOM in place. The document never unloads. There is no
fresh page load to hang anything off. So code that waits for load, or polls the URL on a timer,
either misses the change or arrives late, after the framework has already torn down the DOM your
script was holding onto.
From there it breaks one of two ways. Your variation runs once on first load and never again, so every later view falls back to the control. Or it does run again, but against a DOM the framework just replaced, so its selectors hit nothing and it does nothing. Optimizely ships conditional and SPA activation modes, and they do work, but you have to wire them to your own router. The defaults rarely match a real React, Vue, or Angular app, and that is the bit you discover in production rather than in the editor.
The fix: activation that follows the route
The shape of the answer is the same on any framework or platform, and on VWO, Convert, or Adobe Target as
much as Optimizely. You watch for real route changes by
wrapping history.pushState and replaceState and listening for
popstate, so you catch every navigation instead of only the first paint. On each change
you decide whether the experiment even belongs on the new view before touching anything. If it does,
you apply it in a way that survives running many times over. And when the route moves on, you take
your changes back out so they do not pile up behind you.
The reliable way to get that signal is to patch the History API at the source rather than infer it from the DOM. It catches forward navigation and back or forward buttons, and it costs nothing between routes. Pair it with a poll for the real page signals so you stop guessing at load delays.
// Route detection at the source, then poll for the page
// Detect SPA navigations at the source: patch History once, plus popstate.
(function patchHistory() {
if (window.__abHistoryPatched) return; // never stack patches across experiments
window.__abHistoryPatched = true;
const fire = () => window.dispatchEvent(new Event("abRouteChange"));
for (const method of ["pushState", "replaceState"]) {
const original = history[method];
history[method] = function (...args) {
const result = original.apply(this, args);
fire();
return result;
};
}
window.addEventListener("popstate", fire);
})();
// On first load and every navigation: tear down the previous view first, then
// poll for the page (do not guess a delay).
const run = () => {
teardown(); // clean up the view you are leaving, on every route change
// If your pollerLite returns a canceller, cancel the previous one here so a
// fast navigation cannot leave a stale poller that fires init late.
pollerLite(
['[data-qaid*="stock-filter-toggle"]', () => window.utag?.data?.basicPageId === "lister Page"],
init
);
};
run();
window.addEventListener("abRouteChange", run);
When you cannot patch History
Patching history is a global side effect, and sometimes you cannot do it safely. The host
app may already wrap it, another live experiment may be fighting you for it, or the framework may
route in a way the patch does not see. When that happens, watch the DOM instead. A
MutationObserver that compares location.href whenever the tree changes gives
you the same route signal without touching anything global. It is heavier, because it fires on every
render rather than only on navigation, so keep the callback cheap and check the URL once per batch.
// The fallback when History cannot be patched
// Non invasive fallback: infer navigation from DOM changes.
const onUrlChange = (callback) => {
let previousUrl = location.href;
const observer = new MutationObserver(() => {
if (location.href === previousUrl) return; // one check per batch, not per mutation
previousUrl = location.href;
callback();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
};
Applying it again without stacking duplicates
The trap on the far side of this is duplication. Reinject the same banner on every route change and you get two, then three. Bind a click handler each time and you get a stack of them firing at once. Memory creeps up, events double, and now you own a second bug sitting on top of the one you just fixed. The answer is two things. Bind delegated listeners once on the body and remove them before you add them, so a second run cannot stack them. Then guard the injection with a marker the code checks before it does anything, and tear the previous injection down on every route change, before you decide whether the new page even qualifies.
// Bind once, guard the inject, tear down when the page no longer qualifies
// clickHandler is a stable, module-level reference, so remove-before-add actually
// matches and the listener binds once, not once per route change.
document.body.removeEventListener("click", clickHandler);
document.body.addEventListener("click", clickHandler);
// teardown runs on every route change (see run, above), so cleanup never depends
// on the new page qualifying.
const teardown = () => {
document.querySelector(".scr319-injected")?.remove();
document.documentElement.classList.remove(ID, `${ID}-${VARIATION}`);
};
// init only runs once the poll confirms a lister page, so it just guards and injects.
const init = () => {
if (document.querySelector(".scr319-injected")) return; // already applied to this view
// inject the variation here, tagged with .scr319-injected
};
Proving it actually bucketed
A variation that renders is not proof that anything counted. The DOM can look right while the decision event never fired on the new route, so the platform never logged the visitor at all. Or it fired twice and logged them wrong. Confirm activation happened on each view, and confirm analytics recorded it exactly once. If your testing tool and GA4 still disagree on the split after that, you are looking at a different problem, usually sample ratio mismatch.
How this played out on a real build
This came up on a Screwfix lister page. The storefront runs on Next.js, so a shopper moving from a category listing into a product and back never triggers a document reload. The first cut of the experiment activated once on load and then went quiet for the rest of the session. It still passed QA, because QA opened lister URLs directly instead of clicking through to them the way a shopper does.
The change that fixed it was small. Treat every route change as a fresh activation, wait for the lister signals again before running (the stock filter control and the tealium page id), reset the per view counters, and run init a second time. The delegated click and change listeners stayed bound to the body the whole time, so only the page setup repeated, never the event wiring.
Ways to re-activate an experiment on SPA navigation
| Method | Catches client-side route changes? | Reliability | Notes |
|---|---|---|---|
| Default activation only | No, it runs once on the hard page load | Looks fine on refresh, dead on real journeys | The state most tests ship in. Every router view after the first falls back to control. |
Poll location.href on an interval | Yes, but late | Works, reacts after the route already changed | A timer that runs forever and fires on a schedule, not on the actual change. Last resort. |
Patch history.pushState and listen for popstate | Yes, at the source | High, catches forward nav and back or forward buttons | Costs nothing between routes. Patch once with a global guard so experiments do not stack patches. |
| Hook the framework router's navigation event | Yes | High when the hook is stable and exposed | Cleanest signal when the router exposes one (Next.js routeChangeComplete, Vue Router afterEach). Ties you to that framework's internals. |
Common questions
Why does my Optimizely experiment stop firing after navigation?
In a single page app the document never reloads on navigation, so activation that runs on page load only fires once. Every later view the router renders never re-triggers it, so the variation is gone and nothing buckets on those views.
How do I re-run an experiment on SPA route changes?
Detect the route change, then re-trigger activation and re-apply the variation. Patch history.pushState and replaceState and listen for popstate, then on each change tear down the previous variation, check whether the experiment belongs on the new view, and apply it again.
Why does it work on refresh but not when I click through the app?
A refresh is a hard page load, which re-runs activation, so it always looks fine. Clicking through is client side navigation with no document reload, so activation never re-runs. That split is why the bug slips past QA.
Should I poll location.href to detect route changes?
It works as a last resort, but it adds a timer that runs forever and reacts after the route has already changed. Patching the History API catches forward navigation and the back and forward buttons at the source and costs nothing between routes, so prefer it.
How do I stop duplicate variations stacking up on each navigation?
Tear down the previous variation on every route change before you decide whether the new view qualifies, and guard the injection with a marker. Bind delegated listeners once on the body with remove before add, so a second activation cannot stack a second handler.
The short version
If a test works on refresh but dies in the journey, this is almost always why: activation that fires on page load running headfirst into routing that happens entirely in the browser. Hook the route changes, apply the variation so it can safely run again, clean up after every view, and confirm people actually bucketed rather than trusting that the page looks right.