How to correctly handle SPA routing in Optimizely, with real-world patterns, examples, and common mistakes to avoid
Edit me

Single Page Applications (SPAs) introduce unique challenges for A/B testing tools. Unlike traditional multi-page websites, SPAs do not reload the page on navigation, which directly impacts how tool evaluates audiences, runs experiments, and tracks impressions.

This section explains how to correctly handle SPA routing in Optimizely, with real-world patterns, examples, and common mistakes to avoid.

Why SPA Routing Is Tricky

In an SPA:

  • URL changes happen via JavaScript (no reload)
  • DOM elements load asynchronously
  • Experiment code may execute before elements exist
  • Route changes may not automatically trigger experiment re-evaluation

If not handled properly:

  • Users may never enter the experiment
  • Variants may apply on the wrong route
  • Impressions may never fire
  • Metrics become unreliable

Optimizely’s SPA Execution Model

Optimizely Experiment Activation Settings

The screenshot above shows Optimizely → Experiment → Activation, which controls when Optimizely evaluates page conditions and activates an experiment.

By default, in Optimizely:

  • Experiment code runs once on the initial page load
  • SPA navigation does not automatically re-run activation
  • DOM changes are not guaranteed to be ready at activation time

For SPAs, Optimizely relies on:

  • Manual or trigger-based re-evaluation
  • Dynamic element detection
  • Optional callback-based activation

This is why correct trigger selection and DOM readiness handling are critical.

Page Activation Triggers in SPAs

1. “Immediately” (Default Trigger)

What it does

  • Evaluates page conditions as soon as the page loads
  • Designed for traditional multi-page websites

Why it fails in SPAs

  • SPA routes load after the initial page load
  • Components render asynchronously
  • DOM elements may not exist yet

Result

  • URL condition may pass ❌
  • DOM is missing ❌
  • Experiment never activates

2. “When the URL changes” (SPA-friendly)

What it does

  • Re-evaluates page conditions when the browser URL changes
  • Works with history.pushState / replaceState

When to use

  • Route-based experiments (e.g. /checkout, /configure/step-2)
  • React, Angular, Vue, Next.js applications

Limitations

  • URL can change before DOM is ready
  • Does not guarantee element availability

Best practice

  • Use this trigger together with defineOptiReady(), not instead of it.

3. “When the DOM changes” (Powerful but risky)

What it does

  • Activates the experiment on any DOM mutation

Pros

  • Useful when elements are injected dynamically
  • Can catch late-rendered components

Cons

  • Can fire multiple times
  • High risk of duplicate execution
  • Potential performance impact on large SPAs

When to use

  • When routing hooks are unreliable
  • When element timing is unpredictable

Golden rule Always guard execution with a global flag:

if (window.__optiExpApplied) return;
window.__optiExpApplied = true;

4. “When a callback is called” (Most control)

What it does

  • Allows developers to manually activate the page via JavaScript

Why this is powerful

  • Full control over when Optimizely runs
  • Can align perfectly with:
    • Router events
    • Data readiness
    • Component mount completion

This is ideal for complex, high-impact SPA experiments owned by frontend teams.

How This Relates to defineOptiReady()

Even with the correct activation trigger:

  • Optimizely may activate before elements exist
  • React/Vue re-renders may replace DOM nodes after activation

That’s why teams combine:

  • Correct trigger selection
  • defineOptiReady() for DOM stability
  • Execution guards to prevent duplication

This combination is the most stable Optimizely SPA pattern in production.

Using defineOptiReady() for Dynamic Elements

In most Optimizely SPA implementations, teams use a helper like defineOptiReady() to safely run experiment logic when dynamic elements appear.

This approach is preferred over raw querySelector checks.

Why defineOptiReady() Is Needed

  • SPA components mount after API responses
  • React/Vue frequently re-render and replace nodes
  • Direct selectors can fail intermittently

defineOptiReady() ensures:

  • The element exists
  • The experiment runs at the correct time
  • Execution is stable across browsers and devices

Example: Using defineOptiReady() in Optimizely

function defineOptiReady() {
  const listeners = [];
  const doc = window.document;
  const MutationObserver =
    window.MutationObserver || window.WebKitMutationObserver;
  let observer;

  function ready(selector, fn) {
    // Store the selector and callback to be monitored
    listeners.push({
      selector,
      fn,
    });
    if (!observer) {
      // Watch for changes in the document
      observer = new MutationObserver(check);
      observer.observe(doc.documentElement, {
        childList: true,
        subtree: true,
      });
    }
    // Check if the element is currently in the DOM
    check();
  }

  function check() {
    // Check the DOM for elements matching a stored selector
    for (let i = 0, len = listeners.length, listener, elements; i < len; i++) {
      listener = listeners[i];
      // Query for elements matching the specified selector
      elements = doc.querySelectorAll(listener.selector);
      for (let j = 0, jLen = elements.length, element; j < jLen; j++) {
        element = elements[j];
        if (!element.ready) {
          element.ready = [];
        }
        // Make sure the callback isn't invoked with the
        // same listener more than once
        // due to other mutations
        if (!element.ready[i]) {
          element.ready[i] = true;
          // Invoke the callback with the element
          listener.fn.call(element, element);
        }
      }
    }
  }

  // Expose 'ready'
  window.optiReady = ready;
}

Usage inside an Optimizely experiment:

defineOptiReady();
window.optiReady('button[data-test="checkout:banner:save_my_build"]', () => {
  init();
});

Common Mistakes to Avoid

  • Relying only on URL targeting in SPAs
  • Running experiment code only on initial page load
  • Not guarding against re-execution
  • Using raw querySelector without waiting

Optimizely SPA Golden Rules

  1. Detect route changes explicitly
  2. Wait for dynamic elements using defineOptiReady()
  3. Guard experiment execution
Tags: coding