Skip to main content
SeleniumDecoded

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.

Selenium 4 Stable Updated 9 Sept 2026 · Verified against Selenium 4.48.0

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 StaleElementReferenceException inside conditions that re-read elements, or add it to the ignored exceptions.

Lambda Conditions

Inline conditions
Selenium 4 Stable
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Attribute changes
wait.until(d -> "false".equals(d.findElement(By.id("save")).getAttribute("aria-busy")));
// At least N rows, returning the rows for use
List<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 pattern
wait.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 changes
wait.until(lambda d: d.find_element(By.ID, "save").get_attribute("aria-busy") == "false")
# At least N rows, returning the rows for use
rows = wait.until(lambda d: (r := d.find_elements(By.CSS_SELECTOR, "table.orders tbody tr")) if len(r) >= 5 else False)
# Text matches a pattern
wait.until(lambda d: re.fullmatch(r"\$[0-9,]+\.[0-9]{2}", d.find_element(By.ID, "total").text))
# Element gone
wait.until(lambda d: not d.find_elements(By.CSS_SELECTOR, ".spinner"))
// Attribute changes
await driver.wait(async () =>
(await driver.findElement(By.id('save')).getAttribute('aria-busy')) === 'false', 10000);
// At least N rows, returning the rows
const 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 pattern
await driver.wait(async () => /^\$[0-9,]+\.[0-9]{2}$/.test(await driver.findElement(By.id('total')).getText()), 10000);
// Element gone
await driver.wait(async () => (await driver.findElements(By.css('.spinner'))).length === 0, 10000);
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
// Attribute changes
wait.Until(d => d.FindElement(By.Id("save")).GetAttribute("aria-busy") == "false");
// At least N rows, returning the rows
var rows = wait.Until(d => {
var found = d.FindElements(By.CssSelector("table.orders tbody tr"));
return found.Count >= 5 ? found : null;
});
// Text matches a pattern
wait.Until(d => Regex.IsMatch(d.FindElement(By.Id("total")).Text, @"^\$[0-9,]+\.[0-9]{2}$"));
// Element gone
wait.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.

A library of named conditions
Selenium 4 Stable
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 message
wait.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 messages
const 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.

Ignore transient exceptions
Selenium 4 Stable
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 condition
await 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.

Spinner gone AND table populated
Selenium 4 Stable
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 check
WebElement 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-ins
wait.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.

Wait for an app flag or pending requests
Selenium 4 Medium
JavascriptExecutor js = (JavascriptExecutor) driver;
// App exposes a readiness flag
wait.until(d -> Boolean.TRUE.equals(js.executeScript("return window.__APP_READY__ === true")));
// Legacy jQuery apps
wait.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.

Network-idle wait using BiDi events
Selenium 4 Medium
// 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 quiet
new WebDriverWait(driver, Duration.ofSeconds(15)).until(d ->
inFlight.get() <= 0 && System.currentTimeMillis() - lastActivity.get() > 500);
# Enable BiDi: options.enable_bidi = True
import 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 sent
driver.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

Related lessons