PRACTICAL GUIDE / Selenium TypeScript custom wait condition

Wait for business-ready state in Selenium, not a spinner

Write typed Selenium waits that poll real product state, return useful evidence, and time out with the last value instead of a generic failure.

By The Testing AcademyUpdated August 4, 20269 min read
All field guides
In this guide6 sections
  1. Wait for the state the user needs
  2. Return a typed snapshot, not a mystery boolean
  3. Build a wait that explains its timeout
  4. Diagnose the race before increasing timeout
  5. Fix the observable contract and accept its cost
  6. Use a built-in wait when the state is simple

What you will learn

  • Wait for the state the user needs
  • Return a typed snapshot, not a mystery boolean
  • Build a wait that explains its timeout
  • Diagnose the race before increasing timeout

The checkout spinner disappears, so the test clicks Pay. The total still says "Calculating," and the click is rejected because the quote has not finished. Waiting for visibility solved the wrong race.

Wait for the state the user needs

Page load completion says little about a modern application's business state. Selenium waits for navigation according to the configured page-load strategy, but JavaScript can continue fetching prices, validating stock, or enabling controls after the document is ready.

An explicit wait should describe the condition that makes the next user action valid. For checkout, that might mean all of the following are true at the same time:

  • the quote panel exists
  • its status is ready
  • the total has a valid currency value
  • a request identifier shows which quote produced that value

Element presence alone is too weak. Spinner absence is also weak because error paths often remove a spinner. A disabled button becoming enabled can be useful, but only if the product guarantees it reflects the complete quote state.

The JavaScript Selenium binding accepts a custom function in driver.wait(condition, timeout, message, pollTimeout). Selenium repeatedly evaluates that function until it returns a truthy value. If the function returns a promise, Selenium awaits it and counts that time toward the timeout. The first truthy value becomes the resolved result of the wait.

Errors are not silently retried. They propagate. This is a valuable boundary: "not ready yet" should return a falsy value, while an invalid selector, closed session, or unexpected parsing failure should stop immediately. Catching every exception and returning false turns real defects into slow, generic timeouts.

Return a typed snapshot, not a mystery boolean

A boolean tells the caller that something became true. A typed snapshot tells it exactly what Selenium observed when the wait ended. That removes a second page read, which could see a newer state after another re-render.

The snapshot should contain business evidence, not a WebElement. Returning the element leaks remote identity and lets it become stale before the caller uses it. For the quote example, integer cents, status, request ID, poll count, and elapsed time are enough.

Keep the predicate read-only. Selenium may call it dozens of times. A condition that clicks, submits, or changes data can repeat that action whenever the first attempt returns a falsy value. Idempotent observation makes polling safe.

Use findElements() when absence is an expected intermediate state. It returns an empty array instead of throwing NoSuchElementError, which lets the condition record "panel missing" and continue. Once the panel exists, malformed values should still be handled deliberately. A temporary Calculating... value is non-readiness; an impossible value such as free-ish may deserve an immediate error if the product contract forbids it.

Build a wait that explains its timeout

This complete TypeScript script creates a page whose quote becomes ready after 350 milliseconds. The wait locates fresh elements on each poll, returns a typed result, and includes the last observed state in any timeout.

TypeScript
import { strictEqual } from 'node:assert';
import { Browser, Builder, By, WebDriver } from 'selenium-webdriver';

type ReadyQuote = Readonly<{
  status: 'ready';
  totalCents: number;
  requestId: string;
  polls: number;
  elapsedMs: number;
}>;

class CheckoutPage {
  constructor(private readonly driver: WebDriver) {}

  async waitForQuote(timeoutMs = 3_000): Promise<ReadyQuote> {
    const startedAt = Date.now();
    let polls = 0;
    let lastObserved = 'quote panel not found';

    const result = await this.driver.wait(
      async (): Promise<ReadyQuote | false> => {
        polls += 1;
        const panels = await this.driver.findElements(
          By.css('[data-testid="quote"]'),
        );

        const panel = panels[0];
        if (panel === undefined) {
          lastObserved = 'quote panel not found';
          return false;
        }

        const status = await panel.getAttribute('data-status');
        const requestId = await panel.getAttribute('data-request-id');
        const totalText = await panel
          .findElement(By.css('[data-testid="quote-total"]'))
          .getText();

        lastObserved = JSON.stringify({ status, requestId, totalText, polls });
        const money = /^\$(\d+)\.(\d{2})$/.exec(totalText);
        const dollars = money?.[1];
        const cents = money?.[2];

        if (
          status !== 'ready' ||
          requestId === '' ||
          dollars === undefined ||
          cents === undefined
        ) {
          return false;
        }

        return {
          status: 'ready',
          requestId,
          totalCents: Number(dollars) * 100 + Number(cents),
          polls,
          elapsedMs: Date.now() - startedAt,
        };
      },
      timeoutMs,
      () => `Quote did not become ready. Last observed: ${lastObserved}`,
      100,
    );

    if (result === false) {
      throw new Error('WebDriver returned a falsy wait result');
    }

    return result;
  }
}

async function main(): Promise<void> {
  const driver = await new Builder().forBrowser(Browser.CHROME).build();

  try {
    const html = `
      <section data-testid="quote" data-status="loading" data-request-id="">
        <span data-testid="quote-total">Calculating...</span>
      </section>
      <script>
        setTimeout(() => {
          const quote = document.querySelector('[data-testid=quote]');
          quote.dataset.status = 'ready';
          quote.dataset.requestId = 'quote-417';
          quote.querySelector('[data-testid=quote-total]').textContent = '$42.50';
        }, 350);
      </script>
    `;

    await driver.get(
      `data:text/html;charset=utf-8,${encodeURIComponent(html)}`,
    );

    const checkout = new CheckoutPage(driver);
    const quote = await checkout.waitForQuote();

    strictEqual(quote.status, 'ready');
    strictEqual(quote.requestId, 'quote-417');
    strictEqual(quote.totalCents, 4250);
  } finally {
    await driver.quit();
  }
}

void main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});

The guard at the return boundary narrows the TypeScript union created by the falsy polling value. At runtime, driver.wait() resolves only with the first truthy result or rejects on timeout. Callers receive ReadyQuote, not ReadyQuote | false.

The timeout message is a function, so it reports the latest observation rather than the state that existed when the wait began. If CI fails with status: "ready" and totalText: "Calculating...", the status flag is being set before the amount. If it ends on quote panel not found, investigate navigation, frame context, selector ownership, or an earlier action.

Diagnose the race before increasing timeout

Run the smallest compiled reproduction by itself:

Shell
node dist/checkout-wait.js

For a real failing spec, keep one worker and one browser while diagnosing. Record the poll count, elapsed time, last observed state, current URL, and Selenium session ID in the failure attachment. A screenshot shows what a person saw, while the last snapshot explains what the predicate read.

Failure timing separates several causes. An immediate exception means the condition threw, perhaps because of an invalid selector or closed session. A timeout with changing observations means the product is progressing but never reaches the complete contract. A timeout with the same value on every poll points to a stuck request, wrong test data, or a predicate reading the wrong region.

Bound the diagnostic value. Last-observed state should be a short snapshot, not the whole page source or response body on every poll. Replacing one in-memory string, as the example does, keeps evidence useful without growing with the timeout. Redact tokens, email addresses, and payment data before putting that snapshot in a report.

Record the state that satisfied the wait as well as timeout state. If a later assertion fails, the returned polls, elapsedMs, and request ID show that synchronization completed and identify the version of data the test accepted. That prevents the team from increasing a wait that already succeeded and directs attention to the action or assertion after it.

If elapsed time greatly exceeds the configured timeout, inspect implicit waits. Every findElement or findElements call can be affected by the session's implicit timeout. Nesting that delay inside a custom polling loop makes duration hard to predict. Selenium's documentation warns against mixing implicit and explicit waits; keep implicit wait at zero when the explicit helper owns synchronization.

A stale-element error means the node was replaced between locating and reading it. This example re-locates the panel each poll, which reduces the window but cannot eliminate a replacement between two commands. If that re-render is an expected intermediate state, catch only StaleElementReferenceError, record it, and allow another poll. Do not catch every WebDriver error under the label of "transient."

Remote Grid latency affects poll cost. A single poll here can issue four commands. Polling every 25 milliseconds will not make a four-command round trip finish in 25 milliseconds, and it can add load to the Grid. Measure before using aggressive intervals.

Fix the observable contract and accept its cost

The strongest fix is a product state that users and tests can both observe. A correctly disabled Pay button, a status message, or a completed total can define readiness without test-only knowledge. This may require product work, but it also improves accessibility and prevents real users from acting too soon.

A dedicated data-status value is cheaper to automate and more explicit. Its cost is coupling the test to an internal state name. Treat it as a maintained interface, and pair it with visible behavior so the test does not pass while the user still sees an unusable screen.

Returning a snapshot adds parsing code. The benefit is better evidence and one consistent interpretation of the UI. Keep parsing close to the page or component object. Do not duplicate currency regular expressions across tests.

If several pages wait for the same domain transition, share the parser and state type, not a generic "wait until" wrapper that accepts arbitrary callbacks. The latter saves a few lines while hiding which product condition timed out. A named helper such as waitForQuote() gives logs and stack traces useful vocabulary.

The wait should also have one timeout owner. Passing a timeout through five helper layers makes it easy for a test to set 30 seconds without anyone seeing the cost. Keep a documented default near the condition and allow overrides only for scenarios with measured reasons.

Increasing the timeout is appropriate when the existing condition is correct and measured environments legitimately need more time. It raises worst-case suite duration and delays detection of a stuck state. A bigger number does not repair a predicate that waits for the wrong signal.

Faster polling can reduce reaction time for a cheap local condition. On Grid it increases command traffic, log volume, and the chance of reading across a re-render. Start with a moderate interval such as 100 to 250 milliseconds, then use measurements from the actual environment.

Use a built-in wait when the state is simple

Do not write a custom predicate for ordinary element presence or visibility. Selenium's built-in until conditions are shorter, familiar to reviewers, and less likely to swallow an error.

Avoid polling an API indirectly through the browser when the test is really an API contract. Verify the service at the API layer, then keep one browser test for the user-visible integration.

Never put a non-idempotent action inside the condition. A repeated click or form submission can create the flake the wait was meant to solve.

Skip a custom wait when the application already emits a reliable, accessible ready state and an existing condition can observe it. Custom synchronization earns its maintenance cost only when several low-level observations must become one business decision, and when a timeout can explain which part never became ready.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

How do I create a custom wait in Selenium with TypeScript?

Pass an async function to `driver.wait()`. Return `false`, `null`, or another falsy value while the product is not ready, then return a truthy typed value when the required state is complete.

Does driver.wait ignore errors thrown by my condition?

No. Errors raised while evaluating a custom function propagate and stop the wait. Catch only a known transient condition, such as an expected stale reference during a documented re-render, and let session or selector errors fail immediately.

How can I change the polling interval in selenium-webdriver?

Use the fourth argument of `driver.wait(condition, timeout, message, pollTimeout)`. Faster polling reacts sooner but sends more WebDriver commands, which can be expensive on a remote Grid.

Why can an explicit wait run longer than its timeout?

Mixing implicit and explicit waits can make each element lookup consume its own wait before the outer condition polls again. Keep implicit wait at zero when precise explicit-wait timing matters.

Should a custom wait return a boolean or the ready data?

A typed snapshot is often more useful because the caller can assert the exact state that ended the wait without reading the page again. A boolean is enough when no resulting value is needed and the timeout message already captures useful diagnostics.