Wait Until
waitUntil solves a common problem in A/B testing: what do you do when the element your variation needs does not exist in the DOM yet? Modern websites render content dynamically, and your variation code may run before that content is ready. waitUntil lets you safely wait for any CSS selector, JavaScript global, or custom condition to become available, then chains naturally with .then().
There are two forms: options.waitUntil (available inside trigger and variation code), which is automatically cancelled when the variation is torn down; and avsb.waitUntil (available anywhere on the page), which you can cancel manually via .stop(). Both share the same signature.
options.waitUntil rather than avsb.waitUntil. The options version is automatically stopped on variation removal and SPA navigation — no manual cleanup needed.Signature
Both forms share the same overloaded signature. Pass a single target or an array of targets; receive a GuardedThenable you chain with .then():
1// Single target — resolves with the value the target returned2waitUntil(target: WaitTarget, opts?: WaitOptions): GuardedThenable<unknown>3
4// Multiple targets — resolves with an array of results (all must be ready)5waitUntil(targets: WaitTarget[], opts?: WaitOptions): GuardedThenable<unknown[]>6
7// A target can be any of:8type WaitTarget =9 | string // CSS selector e.g. ".hero-banner"10 | string // window path e.g. "window.dataLayer"11 | (() => unknown) // predicate e.g. () => window.userProfile12
13interface WaitOptions {14 timeout?: number // ms before the wait times out — default 1000015 all?: boolean // when true, selector resolves via querySelectorAll (array)16}17
18// GuardedThenable — like a Promise, but .catch() is OPTIONAL19interface GuardedThenable<T> {20 then(onFulfilled?, onRejected?): GuardedThenable<...>21 catch(onRejected?): GuardedThenable<...>22 finally(onFinally?): GuardedThenable<...>23 stop(): void // cancel the wait — thenable never settles after this24}GuardedThenable is not a native Promise. Omitting .catch() will never raise an unhandled-rejection error. A failed or timed-out wait is logged gracefully instead — verbose in preview mode, silent in production. This means you can write options.waitUntil('.hero').then(applyVariant) without a trailing .catch() and the page will not throw if the element never arrives.Parameters
targetstring | (() => unknown)RequiredWhat to wait for. A CSS selector string (e.g. ".hero-banner") resolves once that element exists in the DOM. A dot-separated window path string (e.g. "window.dataLayer") resolves once that global is truthy. A predicate function is called repeatedly — it resolves once it returns a truthy value. Pass an array of these to wait for several conditions at once.
opts.timeoutnumberOptionalMaximum milliseconds to wait before giving up. Defaults to 10000 (10 seconds). On timeout the GuardedThenable rejects, but because .catch() is optional the failure is only logged, not thrown.
opts.allbooleanOptionalWhen true and the target is a CSS selector string, the wait resolves with an array of all matching elements (querySelectorAll) instead of just the first one.
Return value
waitUntil returns a GuardedThenable — a promise-like object you can chain. Key differences from a native Promise:
.catch()is optional. A rejected GuardedThenable logs the failure rather than raising an unhandled rejection..stop()cancels the underlying wait engine. Afterstop()the thenable never settles, logs, or calls any chained handlers. Use this in teardown code.- Chaining with
.then()/.catch()/.finally()returns another GuardedThenable — the catch-optional guarantee applies to every link in the chain.
Target types
CSS selector
Pass a string that does not start with window. and it is treated as a CSS selector. The wait resolves once document.querySelector(selector) returns a non-null element, passing that element to .then().
1// Inside trigger / variation code — auto-cleaned on removal2options.waitUntil('.hero-banner').then((banner) => {3 banner.classList.add('variant-style');4
5 options.onRemove(() => {6 banner.classList.remove('variant-style');7 });8});1options.waitUntil('.product-card', { all: true }).then((cards) => {2 cards.forEach((card) => card.classList.add('highlighted'));3});Window global path
Pass a dot-separated string starting with window. (or a plain path like window.dataLayer) and the wait polls until that global is truthy, then resolves with its value.
1options.waitUntil('window.userProfile').then((profile) => {2 if (profile.isPremium) {3 const banner = document.querySelector('.upgrade-banner');4 if (banner) banner.remove();5 }6});Predicate function
Pass a function. It is called repeatedly until it returns a truthy value, which is then passed to .then(). The predicate should only read state — do not modify the DOM or trigger network requests inside it.
1options.waitUntil(() => window.Intercom && document.querySelector('#intercom-container'))2 .then((container) => {3 container.classList.add('variant-position');4
5 options.onRemove(() => {6 container.classList.remove('variant-position');7 });8 });Multiple targets (array)
Pass an array of targets. The wait resolves once all targets are ready, passing an array of their resolved values to .then(). Each element can be a selector, window path, or predicate — they may be different types.
1options.waitUntil(['.checkout-form', 'window.cart', () => window.Stripe])2 .then(([form, cart, stripe]) => {3 // All three are guaranteed ready here4 form.querySelector('.submit-btn').textContent = 'Complete Order';5 });Timeout behaviour
The default timeout is 10 000 ms (10 seconds). When the timeout expires the GuardedThenable rejects internally, but because.catch() is optional the failure is only logged — verbose in preview mode (so you see it in DevTools), silent for real visitors.
Pass a custom timeout in the options object:
1// Give up after 5 seconds2options.waitUntil('.dynamic-widget', { timeout: 5000 }).then((el) => {3 el.classList.add('variant');4});If the condition you are waiting for might never appear — for example, an element that only exists on certain pages — pass a reasonable timeout so polling does not run forever. For a wait you want to run indefinitely, pass timeout: 0 to disable the timeout entirely (use with caution).
Stopping a wait early
Call .stop() on the returned GuardedThenable to cancel the wait before it resolves:
1const wait = options.waitUntil('.checkout-form').then((form) => {2 form.querySelector('.submit-btn').textContent = 'Complete Order';3});4
5// Cancel if the experiment decides to abort early6someCondition && wait.stop();When using options.waitUntil inside variation or trigger code, stop() is called automatically on variation removal and SPA navigation. You only need to call it manually when you want to cancel before that lifecycle event fires.
Migrating from the old callback API
The previous waitUntil API used a three-argument callback form that returned a { cancel } handle:
1// OLD — callback-based2options.waitUntil(3 () => document.querySelector('.hero'),4 (el) => { el.classList.add('variant'); },5 { timeout: 5000 }6);The new API is promise-based. Migrate by moving the callback into a .then() chain:
1// NEW — chain .then() on the target2options.waitUntil('.hero', { timeout: 5000 }).then((el) => {3 el.classList.add('variant');4});Key differences to keep in mind when migrating:
- The first argument is now the target (selector, window path, or predicate) — not a wrapper function. If you previously wrote
() => document.querySelector('.x')as the condition, replace it with the selector string'.x'directly. - The returned
handle.cancel()is now.stop()on the GuardedThenable itself. - No
.catch()is required — failures log gracefully instead of throwing.
window.setTimeout(callback, 1000) hoping 1 second is long enough for a dynamic element. This is fragile: slow connections may not be ready in time; fast connections wait unnecessarily. options.waitUntil reacts the instant the condition is met.