Code snippets
How to Wait for Dynamically Loaded Elements in an A/B Test
★★★ Technical level 3 of 3
Written by Neil Webley · Last updated
Wait for dynamically loaded DOM elements with MutationObserver, bounded timeouts and SPA-safe idempotent transformations.
Quick answer
Check for the element immediately, then use a bounded MutationObserver only if it is absent. Disconnect before transforming it. In a FreeCROTool hand-coded test, cro_tests.waitForElement(selector, timeoutMs, root) implements this one-shot pattern.
Why is an element not in the DOM when JavaScript runs?
Applications often render product recommendations, checkout steps, search results and account widgets after the initial HTML. The content may depend on an API request, hydration, route transition, consent decision or third-party script. A selector returning null does not necessarily mean it is wrong; it may simply be early.
A fixed delay is unreliable. Fast visitors wait unnecessarily, while slow devices still miss the element. Watching for the actual DOM change is more dependable.
Wait for an element in FreeCROTool
cro_tests.waitForElement(
'[data-test="delivery-message"]',
3000
).then(function (message) {
if (!message) return;
message.textContent = 'Free delivery over £40';
});
The helper checks immediately. If absent, it observes child additions. It disconnects before resolving, so changing the element inside .then() cannot retrigger that wait. It resolves with the first matching element or null
Use the optional third argument to search and observe a stable container instead of the whole document:
var checkout = document.querySelector('[data-test="checkout"]');
cro_tests.waitForElement('.delivery-message', 3000, checkout)
.then(function (message) {
if (!message) return;
message.classList.add('delivery-message--experiment');
});
Transform immediately when the element already exists
Promise callbacks run in a later microtask. For a flicker-sensitive element, make a synchronous query first and attach the observer only when needed.
function transformHeading(heading) {
if (heading.dataset.fcroChanged === '1') return;
heading.dataset.fcroChanged = '1';
heading.textContent = 'A clearer headline';
}
var heading = document.querySelector('.hero__heading');
if (heading) {
transformHeading(heading);
} else {
cro_tests.waitForElement('.hero__heading', 1500)
.then(function (lateHeading) {
if (lateHeading) transformHeading(lateHeading);
});
}
The data marker also prevents the same node being transformed twice if surrounding setup runs again.
Handle elements replaced by an SPA
waitForElement is intentionally one-shot. If the application destroys and recreates the node, observe a stable parent continuously and mark every replacement before transforming it.
var processed = new WeakSet();
function applyToMatches(root) {
root.querySelectorAll('.delivery-message').forEach(function (element) {
if (processed.has(element)) return;
processed.add(element);
element.classList.add('delivery-message--experiment');
});
}
applyToMatches(document);
var observer = new MutationObserver(function () {
applyToMatches(document);
});
observer.observe(document.body, { childList: true, subtree: true });
Keep continuous observers narrow. Disconnect them when the route or experiment state ends, and use a setup guard so navigation cannot install duplicates.
MutationObserver or polling?
MutationObserver reacts to DOM changes without waking on a timer. Polling can be appropriate when readiness is not represented by DOM insertion, but it must stop on success and after a deadline.
var attempts = 0;
var poll = setInterval(function () {
attempts += 1;
var element = document.querySelector('.third-party-widget.is-ready');
if (element || attempts >= 30) {
clearInterval(poll);
}
if (element) {
element.classList.add('widget--experiment');
}
}, 100);
Do not observe every attribute or run a complex document-wide query on every mutation. Choose a stable selector, smallest practical root and bounded lifecycle. Continue with the complete helper API and SPA-safe event-handler guide.