Guide Optimizely DOM timing

Why your A/B test can't find the element (it rendered late)

Your variation fires, you query for the element, and it is not there. So your code does nothing, or it throws, and the test looks dead on the pages that matter most. The element is real. It just arrived after you went looking for it.

Variation code that queries for a late-mounting element, gets null and throws, with a timeline showing the element only appears at 820ms, after the code ran.

The symptom

The selector returns null. The target gets fetched from an API, or rendered after hydration, or loaded lazily as the user scrolls, and your variation ran before any of that finished. Sometimes a refresh works and sometimes it does not, which is the sign you are racing the page rather than reading it.

Why a fixed timeout is the wrong tool

The usual patch is a setTimeout. Wait a second and a half, then run. It is a guess dressed up as a fix. Too short and you still miss the element on a slow connection. Too long and you flash the old state, or the user has already scrolled past. Networks and devices vary wildly, so whatever number you pick is wrong for a real slice of your traffic.

The fix: wait for the element, then act

Check the DOM now, and if the element is missing, watch for it and act the moment it appears. Give the watcher a timeout so it gives up cleanly instead of running forever on a page where the element never comes. If you already carry a poller in your library, this is the same idea. The approach is tool agnostic, so the same pattern works in Optimizely, VWO, Convert, or Adobe Target.

// Recommended starting point. Swap in your pollerLite or waitForElement.

// Wait for an element instead of guessing a delay. Resolves when it appears, or bails.
function waitForElement(selector, { timeout = 5000 } = {}) {
  return new Promise((resolve, reject) => {
    const found = document.querySelector(selector);
    if (found) return resolve(found);

    const observer = new MutationObserver(() => {
      const el = document.querySelector(selector);
      if (el) {
        observer.disconnect();
        clearTimeout(timer);
        resolve(el);
      }
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });

    const timer = setTimeout(() => {
      observer.disconnect();
      reject(new Error(`waitForElement timed out: ${selector}`));
    }, timeout);
  });
}

// usage
waitForElement('[data-qaid="add-to-basket"]')
  .then((el) => {
    // apply the variation
  })
  .catch(() => {
    // element never appeared, fail quietly
  });

Do not leave the observer running

Disconnect as soon as you have what you need, and on timeout. A MutationObserver left watching the whole document is a quiet tax on every interaction after that, and on a busy page it adds up fast.

How this played out on a real build

On a Screwfix promo test the variation had to drop a banner into specific product cards on the listing page. Those cards are Next.js components that mount after hydration, and mount again every time a shopper filters, sorts, or pages through results. So a single querySelectorAll on load either found nothing or found a set that was about to be thrown away.

Polling for the app to be ready got me close, but the version that actually held waited for the cards themselves before injecting, and ran the whole pass again on each route change after clearing the previous banners. The rule that stuck: anything you read on a listing page, read it when it is there, not a second after you hoped it would be.

Ways to wait for a late element, compared

ApproachFires when the element appears?CostUse when
Fixed setTimeoutNo, it fires on a guessAdds delay for every visitor, and still misses on slow loadsA known fixed delay, never for "has it rendered yet"
Polling (setInterval)Yes, on the next tickRe-checks on a schedule whether or not the DOM changedA fallback when you cannot scope an observer
MutationObserver (waitForElement)Yes, the moment it is insertedLow, it fires only on real DOM changesThe default for waiting on a late-rendering element

Common questions

Why does my A/B test return null when I select an element?

The element has not rendered yet when your code runs. Modern apps build much of the page after the initial HTML, so a selector that runs on load finds nothing. It is a timing problem, not a selector problem.

Why is a fixed setTimeout the wrong way to wait?

A fixed delay is a guess. Too short and the element still is not there; too long and you add lag for every visitor, including the ones where it was ready instantly. It breaks across slow networks and different page types.

Is MutationObserver better than polling?

Usually yes. It fires only when the DOM actually changes, so it is cheaper and more responsive than an interval that re-checks on a fixed schedule. Polling is a fine fallback when you cannot scope an observer.

Do I need to disconnect the observer?

Yes. Leave it running and it keeps firing on every DOM change for the life of the page, wasting CPU and risking a re-trigger. Disconnect it the moment you find the element, or when a timeout failsafe fires.

The short version

If the selector comes back empty, do not reach for a delay. Wait for the element, act once, and disconnect. Reliable beats fast here, and the user never sees the difference anyway.

← Back to all work