Explicit Waits with WebDriverWait and ExpectedConditions

7 min read

Mobile apps have asynchronous UIs: animations complete, network calls return, view transitions settle. Tests that interact too early get NoSuchElementException or ElementNotInteractableException. Explicit waits — not Thread.sleep() — are the correct solution.

Why not Thread.sleep()

Thread.sleep(3000) always waits 3 seconds, even when the element appears in 200ms. On a suite with 200 tests, that's 10 minutes of guaranteed wasted time. Worse, on a slow device or CI machine, 3 seconds may not be enough, so you increase to 5 seconds and waste even more.

Explicit waits poll until the condition is true or the timeout expires. If the element appears in 200ms, the wait returns in 200ms.

WebDriverWait basics

import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.support.ui.ExpectedConditions;
import java.time.Duration;
 
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
 
// Wait for element to be visible
WebElement element = wait.until(
    ExpectedConditions.visibilityOfElementLocated(By.id("submitButton"))
);
 
// Wait for element to be clickable (visible + enabled)
WebElement button = wait.until(
    ExpectedConditions.elementToBeClickable(AppiumBy.accessibilityId("login"))
);
 
// Wait for text to appear
wait.until(ExpectedConditions.textToBePresentInElementLocated(
    AppiumBy.accessibilityId("status"),
    "Order placed"
));

Common ExpectedConditions for mobile

ConditionWhen to use
visibilityOfElementLocatedElement exists in DOM and has non-zero dimensions
elementToBeClickableElement is visible AND enabled (handles disabled buttons)
invisibilityOfElementLocatedWait for loading spinner to disappear
presenceOfElementLocatedElement is in DOM (may be invisible — use sparingly)
textToBePresentInElementLocatedWait for async value update
numberOfElementsToBeMoreThanWait for list to populate

Custom wait conditions

ExpectedConditions doesn't have everything. Write lambdas for custom conditions:

// Wait for element count to stabilise (list finished loading)
wait.until(driver -> {
    List<WebElement> items = driver.findElements(
        AppiumBy.accessibilityId("listItem")
    );
    return items.size() > 0 ? items : null;
});
 
// Wait for attribute to change
wait.until(driver -> {
    WebElement toggle = driver.findElement(AppiumBy.accessibilityId("darkModeToggle"));
    return "true".equals(toggle.getAttribute("checked"));
});
 
// Wait for app to be on a specific screen (Android)
wait.until(driver -> {
    String activity = ((AndroidDriver) driver).currentActivity();
    return activity.contains("HomeActivity");
});

The lambda returns null or false to signal "keep waiting", and any non-null/non-false value to signal "condition met — return this".

FluentWait for fine-grained control

FluentWait lets you configure polling interval and ignored exceptions independently:

import org.openqa.selenium.support.ui.FluentWait;
 
FluentWait<AppiumDriver> wait = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(15))
    .pollingEvery(Duration.ofMillis(500))
    .ignoring(NoSuchElementException.class)
    .ignoring(StaleElementReferenceException.class);
 
WebElement result = wait.until(driver ->
    driver.findElement(AppiumBy.accessibilityId("searchResult"))
);

pollingEvery(500ms) reduces the check frequency, which matters on slow devices where every findElement call takes 100ms. The default WebDriverWait polls every 500ms; you can tune this per-wait.

Waiting for animations to complete

Many mobile transitions have an animation that runs after a tap. Interacting with elements during the animation causes flake:

// After tap that triggers an animation:
button.click();
 
// Wait for the animated element to reach its final state
new WebDriverWait(driver, Duration.ofSeconds(3))
    .pollingEvery(Duration.ofMillis(200))
    .until(driver -> {
        WebElement panel = driver.findElement(AppiumBy.accessibilityId("expandedPanel"));
        // Panel height stops changing when animation is complete
        int height = panel.getSize().getHeight();
        Thread.sleep(200);
        return height == panel.getSize().getHeight();
    });

This "stable dimension" technique works for slide-in and expand animations.

Waiting for loading spinners to disappear

After triggering an async operation, wait for the spinner to disappear before asserting on results:

private static final By LOADING_SPINNER = AppiumBy.accessibilityId("loadingIndicator");
 
public void waitForLoadingToComplete() {
    // First, wait for the spinner to appear (may take up to 1 second)
    try {
        new WebDriverWait(driver, Duration.ofSeconds(1))
            .until(ExpectedConditions.visibilityOfElementLocated(LOADING_SPINNER));
    } catch (TimeoutException e) {
        // Spinner may have appeared and disappeared before we checked
        return;
    }
 
    // Then wait for it to disappear
    new WebDriverWait(driver, Duration.ofSeconds(30))
        .until(ExpectedConditions.invisibilityOfElementLocated(LOADING_SPINNER));
}

Don't skip the "wait for spinner to appear" step — if your test jumps straight to "wait for spinner to disappear" and the spinner hasn't appeared yet, the condition is immediately true and you assert on stale data.

Centralising wait logic

Put common wait patterns in WaitUtils so page objects don't repeat them:

public class WaitUtils {
    private final AppiumDriver driver;
    private final WebDriverWait shortWait;  // 5s for fast operations
    private final WebDriverWait defaultWait; // 15s for network calls
    private final WebDriverWait longWait;   // 30s for slow operations
 
    public WaitUtils(AppiumDriver driver) {
        this.driver = driver;
        this.shortWait = new WebDriverWait(driver, Duration.ofSeconds(5));
        this.defaultWait = new WebDriverWait(driver, Duration.ofSeconds(15));
        this.longWait = new WebDriverWait(driver, Duration.ofSeconds(30));
    }
 
    public WebElement waitForVisible(By locator) {
        return defaultWait.until(ExpectedConditions.visibilityOfElementLocated(locator));
    }
 
    public WebElement waitForClickable(By locator) {
        return defaultWait.until(ExpectedConditions.elementToBeClickable(locator));
    }
 
    public void waitForInvisible(By locator) {
        longWait.until(ExpectedConditions.invisibilityOfElementLocated(locator));
    }
 
    public boolean isPresent(By locator) {
        try {
            shortWait.until(ExpectedConditions.presenceOfElementLocated(locator));
            return true;
        } catch (TimeoutException e) {
            return false;
        }
    }
}

// tip to track lessons you complete and pick up where you left off across devices.