waitUntil

waitUntil solves a common problem: what do you do when the element your variation (one version being tested) needs is missing from the page? Modern websites render content dynamically, so 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 works inside trigger and variation code, and is automatically cancelled when the variation is torn down. avsb.waitUntil works anywhere on the page, and you can cancel it yourself by calling .stop(). Both share the same signature.

Prefer options.waitUntil inside variation code

Inside trigger and variation code, always use options.waitUntil rather than avsb.waitUntil. The options version is automatically stopped on variation removal and SPA navigation, so no manual cleanup is needed.

Signature

Both forms share the same overloaded signature. Pass a single target, or an array of targets, and you get back an AvsbGuardedThenable you chain with .then():

TypeScript
// `avsb.waitUntil` and `options.waitUntil` are both an `AvsbWaitUntilFn`.// Single target: resolves with the value the target produced.declare function waitUntil<T = unknown>(  target: AvsbWaitTarget<T>,  opts?: AvsbWaitOptions): AvsbGuardedThenable<T>// Multiple targets: resolves with an array of results (all must be ready).declare function waitUntil<T extends unknown[]>(  targets: AvsbWaitTarget[],  opts?: AvsbWaitOptions): AvsbGuardedThenable<T>
TypeScript13 lines

The three supporting types ship with @avsbhq/snippet-types:

  • AvsbWaitTarget<T> is string | (() => T): a CSS selector (".hero-banner"), a dot-separated window path ("window.dataLayer"), or a predicate.
  • AvsbWaitOptions is { timeout?: number; all?: boolean }, described under Parameters below.
  • AvsbGuardedThenable<T> is a PromiseLike<T> with .catch(), .finally(), and .stop(), described under Return value below.

Tell waitUntil what you expect, like options.waitUntil<HTMLElement>('.hero'), and every .then() below is typed for you. The examples on this page do that. They are TypeScript. Set the code language to TypeScript in the editor, or drop the type arguments to run them as JavaScript.

.catch() is always optional

AvsbGuardedThenable 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

NameTypeRequiredDescription
targetstring | (() => unknown)YesWhat 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 again and again. It resolves once it returns a truthy value. Pass an array of these to wait for several conditions at once.
opts.timeoutnumberNoMaximum milliseconds to wait before giving up. Defaults to 10000 (10 seconds). On timeout the AvsbGuardedThenable rejects. Because .catch() is optional, the failure is only logged, not thrown.
opts.allbooleanNoWhen 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 an AvsbGuardedThenable: a promise-like object you can chain. It differs from a native Promise in three ways:

  • .catch() is optional: a rejected AvsbGuardedThenable logs the failure instead of raising an unhandled rejection.
  • .stop() cancels the wait, for use in teardown code, after which the thenable never settles, logs, or runs a chained handler again.
  • Chaining with .then(), .catch(), or .finally() returns another AvsbGuardedThenable, and the catch-optional rule 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().

Inside trigger / variation code, auto-cleaned on removal:

TypeScript
// Inside trigger / variation code, auto-cleaned on removaloptions.waitUntil<HTMLElement>('.hero-banner').then((banner) => {  banner.classList.add('variant-style')  options.onRemove(() => {    banner.classList.remove('variant-style')  })})
TypeScript8 lines

Wait for all matching elements with opts.all:

TypeScript
options.waitUntil<HTMLElement[]>('.product-card', { all: true }).then((cards) => {  cards.forEach((card) => card.classList.add('highlighted'))})
TypeScript3 lines

Window global path

Pass a dot-separated string that starts with window. (for example window.dataLayer). The wait polls until that global is truthy, then resolves with its value.

TypeScript
options.waitUntil<{ isPremium: boolean }>('window.userProfile').then((profile) => {  if (profile.isPremium) {    const banner = document.querySelector('.upgrade-banner')    if (banner) banner.remove()  }})
TypeScript6 lines

Predicate function

Pass a function. It is called again and again 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.

TypeScript
// A predicate covers readiness a selector alone cannot express.options.waitUntil(  () => window.Intercom !== undefined && document.querySelector('#intercom-container') !== null).then(() => {  const container = document.querySelector<HTMLElement>('#intercom-container')  if (!container) return  container.classList.add('variant-position')  options.onRemove(() => {    container.classList.remove('variant-position')  })})
TypeScript13 lines

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 can mix types freely.

TypeScript
options.waitUntil<[HTMLFormElement, { total: number }, unknown]>([  '.checkout-form',  'window.cart',  () => window.Stripe,]).then(([form, cart, stripe]) => {  // All three are guaranteed ready here  const submit = form.querySelector<HTMLElement>('.submit-btn')  if (submit) submit.textContent = 'Complete Order'})
TypeScript9 lines

Timeout behaviour

The default timeout is 10 000 ms (10 seconds). When the timeout expires, the AvsbGuardedThenable rejects internally. Because .catch() is optional, the failure is only logged, never thrown: verbose in preview mode, so you see it in DevTools, and silent for real visitors.

Pass a custom timeout in the options object:

TypeScript
// Give up after 5 secondsoptions.waitUntil<HTMLElement>('.dynamic-widget', { timeout: 5000 }).then((el) => {  el.classList.add('variant')})
TypeScript4 lines

The condition you are waiting for might never appear, for example an element that only exists on certain pages. When that is possible, 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, but use that with caution.

Stopping a wait early

Call .stop() on the returned AvsbGuardedThenable to cancel the wait before it resolves:

TypeScript
const wait = options.waitUntil<HTMLFormElement>('.checkout-form').then((form) => {  const submit = form.querySelector<HTMLElement>('.submit-btn')  if (submit) submit.textContent = 'Complete Order'})// Cancel if the experiment decides to abort earlyconst abortEarly = document.body.classList.contains('checkout-error')if (abortEarly) wait.stop()
TypeScript8 lines

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:

Plain text
// OLD: callback-based. Removed, and shown here only for comparison.options.waitUntil(  () => document.querySelector('.hero'),  (el) => { el.classList.add('variant'); },  { timeout: 5000 });
Plain text6 lines

The new API is promise-based. Migrate by moving the callback into a .then() chain:

TypeScript
// NEW: chain .then() on the targetoptions.waitUntil<HTMLElement>('.hero', { timeout: 5000 }).then((el) => {  el.classList.add('variant')})
TypeScript4 lines

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 AvsbGuardedThenable itself.
  • No .catch() is required. Failures log gracefully instead of throwing.
Prefer waitUntil over setTimeout delays

A common mistake is using 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, and fast connections wait for no reason. options.waitUntil reacts the instant the condition is met.

Was this helpful?