Custom Wait Conditions
Write your own expected conditions with lambdas and classes, wait for attribute and text changes, element counts, JavaScript state, network idleness via BiDi, and compose conditions cleanly.
The built-in expected conditions cover visibility, clickability, text and URLs. Real apps need more: “the spinner is gone and the table has at least five rows,” “the button’s aria-busy attribute is false,” “no network requests have completed in the last 500 ms.” Every wait accepts a function, so you can express any of these in a few lines. This lesson shows the patterns and the traps.
How a Wait Evaluates a Condition
WebDriverWait.until(condition) calls the condition repeatedly (every 500 ms by default) until it returns something truthy, then returns that value. null, false and exceptions listed in the ignore set (by default NoSuchElementException) count as “not yet.” Any other exception aborts the wait immediately. Two consequences:
- Return the useful thing (the element, the list, the value) so the caller can use it without a second lookup.
- Catch
StaleElementReferenceExceptioninside conditions that re-read elements, or add it to the ignored exceptions.
Lambda Conditions
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Attribute changeswait.until(d -> "false".equals(d.findElement(By.id("save")).getAttribute("aria-busy")));
// At least N rows, returning the rows for useList<WebElement> rows = wait.until(d -> { List<WebElement> found = d.findElements(By.cssSelector("table.orders tbody tr")); return found.size() >= 5 ? found : null;});
// Text matches a patternwait.until(d -> d.findElement(By.id("total")).getText().matches("\\$[0-9,]+\\.[0-9]{2}"));
// Element gone (or never there)wait.until(d -> d.findElements(By.cssSelector(".spinner")).isEmpty());wait = WebDriverWait(driver, 10)
# Attribute changeswait.until(lambda d: d.find_element(By.ID, "save").get_attribute("aria-busy") == "false")
# At least N rows, returning the rows for userows = wait.until(lambda d: (r := d.find_elements(By.CSS_SELECTOR, "table.orders tbody tr")) if len(r) >= 5 else False)
# Text matches a patternwait.until(lambda d: re.fullmatch(r"\$[0-9,]+\.[0-9]{2}", d.find_element(By.ID, "total").text))
# Element gonewait.until(lambda d: not d.find_elements(By.CSS_SELECTOR, ".spinner"))// Attribute changesawait driver.wait(async () =>(await driver.findElement(By.id('save')).getAttribute('aria-busy')) === 'false', 10000);
// At least N rows, returning the rowsconst rows = await driver.wait(async () => {const found = await driver.findElements(By.css('table.orders tbody tr'));return found.length >= 5 ? found : null;}, 10000);
// Text matches a patternawait driver.wait(async () => /^\$[0-9,]+\.[0-9]{2}$/.test(await driver.findElement(By.id('total')).getText()), 10000);
// Element goneawait driver.wait(async () => (await driver.findElements(By.css('.spinner'))).length === 0, 10000);var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
// Attribute changeswait.Until(d => d.FindElement(By.Id("save")).GetAttribute("aria-busy") == "false");
// At least N rows, returning the rowsvar rows = wait.Until(d => { var found = d.FindElements(By.CssSelector("table.orders tbody tr")); return found.Count >= 5 ? found : null;});
// Text matches a patternwait.Until(d => Regex.IsMatch(d.FindElement(By.Id("total")).Text, @"^\$[0-9,]+\.[0-9]{2}$"));
// Element gonewait.Until(d => d.FindElements(By.CssSelector(".spinner")).Count == 0);Reusable Condition Classes and Functions
When the same condition appears in several page objects, give it a name. In Java the idiom is a static factory returning ExpectedCondition<T>; in Python a callable class; in JavaScript and C# a function returning the predicate.
public final class Conditions {
/** Element's attribute equals the expected value. */ public static ExpectedCondition<WebElement> attributeIs(By locator, String attr, String expected) { return new ExpectedCondition<>() { @Override public WebElement apply(WebDriver d) { WebElement el = d.findElement(locator); return expected.equals(el.getAttribute(attr)) ? el : null; } @Override public String toString() { return String.format("%s attribute '%s' to be '%s'", locator, attr, expected); } }; }
/** Element text is stable: unchanged across two consecutive polls. */ public static ExpectedCondition<String> textStable(By locator) { return new ExpectedCondition<>() { private String last; @Override public String apply(WebDriver d) { String now = d.findElement(locator).getText(); boolean stable = now.equals(last); last = now; return stable ? now : null; } }; }
/** At least n elements match. */ public static ExpectedCondition<List<WebElement>> countAtLeast(By locator, int n) { return d -> { List<WebElement> els = d.findElements(locator); return els.size() >= n ? els : null; }; }}
// Usage: a good toString() gives a readable timeout messagewait.until(Conditions.attributeIs(By.id("save"), "aria-busy", "false"));String total = wait.until(Conditions.textStable(By.id("total")));class attribute_is: """Element's attribute equals the expected value; returns the element.""" def __init__(self, locator, attr, expected): self.locator, self.attr, self.expected = locator, attr, expected
def __call__(self, driver): el = driver.find_element(*self.locator) return el if el.get_attribute(self.attr) == self.expected else False
class text_stable: """Element text unchanged across two consecutive polls; returns the text.""" def __init__(self, locator): self.locator = locator self.last = None
def __call__(self, driver): now = driver.find_element(*self.locator).text stable = now == self.last self.last = now return now if stable else False
def count_at_least(locator, n): def _predicate(driver): els = driver.find_elements(*locator) return els if len(els) >= n else False return _predicate
wait.until(attribute_is((By.ID, "save"), "aria-busy", "false"))total = wait.until(text_stable((By.ID, "total")))const { Condition } = require('selenium-webdriver');
// Condition carries a description used in timeout messagesconst attributeIs = (locator, attr, expected) =>new Condition(`${locator} attribute '${attr}' to be '${expected}'`, async (driver) => { const el = await driver.findElement(locator); return (await el.getAttribute(attr)) === expected ? el : null;});
const textStable = (locator) => {let last;return new Condition(`text of ${locator} to be stable`, async (driver) => { const now = await driver.findElement(locator).getText(); const stable = now === last; last = now; return stable ? now : null;});};
const countAtLeast = (locator, n) =>new Condition(`at least ${n} elements for ${locator}`, async (driver) => { const els = await driver.findElements(locator); return els.length >= n ? els : null;});
await driver.wait(attributeIs(By.id('save'), 'aria-busy', 'false'), 10000);const total = await driver.wait(textStable(By.id('total')), 10000);public static class Conditions{ public static Func<IWebDriver, IWebElement> AttributeIs(By locator, string attr, string expected) => d => { var el = d.FindElement(locator); return el.GetAttribute(attr) == expected ? el : null; };
public static Func<IWebDriver, string> TextStable(By locator) { string last = null; return d => { var now = d.FindElement(locator).Text; var stable = now == last; last = now; return stable ? now : null; }; }
public static Func<IWebDriver, IReadOnlyCollection<IWebElement>> CountAtLeast(By locator, int n) => d => { var els = d.FindElements(locator); return els.Count >= n ? els : null; };}
wait.Until(Conditions.AttributeIs(By.Id("save"), "aria-busy", "false"));var total = wait.Until(Conditions.TextStable(By.Id("total")));textStable is worth keeping: it is the right way to wait for a counter or total that animates or updates in several steps.
Ignoring Exceptions During Polling
Conditions that read elements while the DOM re-renders throw StaleElementReferenceException between polls. Tell the wait to ignore it instead of wrapping every condition in try/catch.
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));wait.ignoring(StaleElementReferenceException.class);wait.pollingEvery(Duration.ofMillis(200));
wait.until(d -> d.findElement(By.id("price")).getText().startsWith("$"));wait = WebDriverWait(driver, 10, poll_frequency=0.2, ignored_exceptions=(StaleElementReferenceException,))wait.until(lambda d: d.find_element(By.ID, "price").text.startswith("$"))// selenium-webdriver retries on any falsy/throwing condition only for NoSuchElement;// catch stale references inside the conditionawait driver.wait(async () => {try { return (await driver.findElement(By.id('price')).getText()).startsWith('$');} catch (e) { if (e.name === 'StaleElementReferenceError') return false; throw e;}}, 10000, undefined, 200);var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10)) { PollingInterval = TimeSpan.FromMilliseconds(200) };wait.IgnoreExceptionTypes(typeof(StaleElementReferenceException), typeof(NoSuchElementException));wait.Until(d => d.FindElement(By.Id("price")).Text.StartsWith("$"));Composing Conditions
Java ships ExpectedConditions.and, or, not, and refreshed. Elsewhere, compose with plain boolean logic inside one lambda; it is clearer than nested helpers.
wait.until(ExpectedConditions.and( ExpectedConditions.invisibilityOfElementLocated(By.cssSelector(".spinner")), ExpectedConditions.numberOfElementsToBeMoreThan(By.cssSelector("table.orders tbody tr"), 0)));
// refreshed() re-finds the element if it goes stale during the checkWebElement save = wait.until(ExpectedConditions.refreshed( ExpectedConditions.elementToBeClickable(By.id("save"))));wait.until(lambda d: not d.find_elements(By.CSS_SELECTOR, ".spinner") and len(d.find_elements(By.CSS_SELECTOR, "table.orders tbody tr")) > 0)
# EC.all_of / any_of / none_of exist in Selenium 4 for built-inswait.until(EC.all_of( EC.invisibility_of_element_located((By.CSS_SELECTOR, ".spinner")), EC.presence_of_element_located((By.CSS_SELECTOR, "table.orders tbody tr")),))await driver.wait(async () =>(await driver.findElements(By.css('.spinner'))).length === 0 &&(await driver.findElements(By.css('table.orders tbody tr'))).length > 0, 10000);wait.Until(d => d.FindElements(By.CssSelector(".spinner")).Count == 0 && d.FindElements(By.CssSelector("table.orders tbody tr")).Count > 0);Waiting on JavaScript State
Ask the page directly when the DOM does not expose what you need. Keep the script tiny and side-effect free; it runs every poll.
JavascriptExecutor js = (JavascriptExecutor) driver;
// App exposes a readiness flagwait.until(d -> Boolean.TRUE.equals(js.executeScript("return window.__APP_READY__ === true")));
// Legacy jQuery appswait.until(d -> Boolean.TRUE.equals(js.executeScript("return window.jQuery ? jQuery.active === 0 : true")));
// Fonts loaded (matters for visual tests)wait.until(d -> Boolean.TRUE.equals(js.executeScript("return document.fonts.status === 'loaded'")));wait.until(lambda d: d.execute_script("return window.__APP_READY__ === true"))wait.until(lambda d: d.execute_script("return window.jQuery ? jQuery.active === 0 : true"))wait.until(lambda d: d.execute_script("return document.fonts.status === 'loaded'"))await driver.wait(() => driver.executeScript('return window.__APP_READY__ === true'), 10000);await driver.wait(() => driver.executeScript('return window.jQuery ? jQuery.active === 0 : true'), 10000);await driver.wait(() => driver.executeScript("return document.fonts.status === 'loaded'"), 10000);var js = (IJavaScriptExecutor)driver;wait.Until(d => (bool)js.ExecuteScript("return window.__APP_READY__ === true"));wait.Until(d => (bool)js.ExecuteScript("return window.jQuery ? jQuery.active === 0 : true"));wait.Until(d => (bool)js.ExecuteScript("return document.fonts.status === 'loaded'"));Network Idle With BiDi
The one condition classic WebDriver cannot express is “no requests in flight.” With BiDi enabled you can count request and response events and wait for them to balance and stay balanced for a short quiet period. This is the robust replacement for Thread.sleep(2000) after an action that triggers API calls.
// Enable BiDi: options.setCapability("webSocketUrl", true);import org.openqa.selenium.bidi.module.Network;
Network network = new Network(driver);AtomicInteger inFlight = new AtomicInteger();AtomicLong lastActivity = new AtomicLong(System.currentTimeMillis());
network.onBeforeRequestSent(e -> { inFlight.incrementAndGet(); lastActivity.set(System.currentTimeMillis()); });network.onResponseCompleted(e -> { inFlight.decrementAndGet(); lastActivity.set(System.currentTimeMillis()); });network.onFetchError(e -> { inFlight.decrementAndGet(); lastActivity.set(System.currentTimeMillis()); });
driver.findElement(By.id("apply-filters")).click();
// Idle = nothing in flight and 500 ms of quietnew WebDriverWait(driver, Duration.ofSeconds(15)).until(d -> inFlight.get() <= 0 && System.currentTimeMillis() - lastActivity.get() > 500);# Enable BiDi: options.enable_bidi = Trueimport time
state = {"in_flight": 0, "last": time.time()}
def started(request): state["in_flight"] += 1 state["last"] = time.time()
def finished(response): state["in_flight"] -= 1 state["last"] = time.time()
driver.network.add_request_handler(started) # before request sentdriver.network.add_response_handler(finished) # response completed
driver.find_element(By.ID, "apply-filters").click()
WebDriverWait(driver, 15).until( lambda d: state["in_flight"] <= 0 and time.time() - state["last"] > 0.5)// Enable BiDi: new chrome.Options().enableBidi()const { Network } = require('selenium-webdriver/bidi/network');
const network = await Network(driver);let inFlight = 0, last = Date.now();await network.beforeRequestSent(() => { inFlight++; last = Date.now(); });await network.responseCompleted(() => { inFlight--; last = Date.now(); });await network.fetchError(() => { inFlight--; last = Date.now(); });
await driver.findElement(By.id('apply-filters')).click();
await driver.wait(() => inFlight <= 0 && Date.now() - last > 500, 15000);// Enable BiDi: new ChromeOptions { UseWebSocketUrl = true }var bidi = await driver.AsBiDiAsync();int inFlight = 0; var last = DateTime.UtcNow;
await bidi.Network.OnBeforeRequestSentAsync(e => { Interlocked.Increment(ref inFlight); last = DateTime.UtcNow; });await bidi.Network.OnResponseCompletedAsync(e => { Interlocked.Decrement(ref inFlight); last = DateTime.UtcNow; });await bidi.Network.OnFetchErrorAsync(e => { Interlocked.Decrement(ref inFlight); last = DateTime.UtcNow; });
driver.FindElement(By.Id("apply-filters")).Click();
new WebDriverWait(driver, TimeSpan.FromSeconds(15)) .Until(d => inFlight <= 0 && (DateTime.UtcNow - last).TotalMilliseconds > 500);Filter out long-polling or websocket keep-alive requests by URL in the handlers, or they will never let the counter reach zero. The Python high-level handlers arrived in 4.45; older versions use the lower-level event subscription shown for Java and JavaScript.
Anti-Patterns
- Conditions with side effects (clicking inside a condition). The wait retries; you will click five times.
- Sleeping inside a condition. It just slows polling.
- Swallowing every exception so the wait always times out with an unhelpful message. Ignore only the exceptions you expect.
- One giant condition that checks ten things. Split it so the timeout message tells you which part failed.
Summary
- Any function of the driver is a condition; return the useful value so callers can use it.
- Name reusable conditions and give them descriptive messages for timeouts.
- Ignore stale-element exceptions during polling rather than wrapping every condition.
- BiDi network events give you a real network-idle wait; use it instead of sleeps after API-triggering actions.
Copy-paste recipes for this topic
- Retry on StaleElementReferenceException
Re-find an element and retry the action when the DOM re-renders between locating and using it.
- Wait for Text to Change and Stabilise
Wait until an element's text differs from its previous value and stops changing, for counters, totals and live-updating fields.