Fluent Waits

8 min read

WebDriverWait covers most of what you'll need. FluentWait is what you reach for when "most" isn't enough — when you need a custom polling cadence, when you need to ignore multiple exception types during the wait, or when you need a custom failure message that helps the next person debug. This lesson shows what FluentWait actually adds, when to use it over WebDriverWait, and the small handful of patterns that justify the extra setup. By the end you'll know when "use FluentWait" is the right answer — and when it's just over-engineering.

What FluentWait actually is

Important fact most people miss: WebDriverWait is a subclass of FluentWait. Open the source and you'll see:

public class WebDriverWait extends FluentWait<WebDriver> { ... }

WebDriverWait is FluentWait with three defaults pre-configured:

  • Polling interval: 500 milliseconds
  • Ignores: NoSuchElementException
  • Timeout: whatever you pass into the constructor

Use FluentWait directly when you want to override any of those, or add multiple ignored exception types, or wait on a non-WebElement value.

A minimal FluentWait

import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.By;
 
import java.time.Duration;
 
FluentWait<WebDriver> fluent = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(30))
    .pollingEvery(Duration.ofMillis(500))
    .ignoring(NoSuchElementException.class)
    .ignoring(StaleElementReferenceException.class)
    .withMessage("Dynamic content didn't appear after 30 seconds");
 
WebElement element = fluent.until(driver ->
    driver.findElement(By.id("dynamic-content"))
);

Read it as a builder chain:

  • .withTimeout(...) — total max wait.
  • .pollingEvery(...) — interval between condition evaluations.
  • .ignoring(ExceptionType.class) — exceptions to swallow during polling. Stack as many as you need.
  • .withMessage(...) — what to say in the TimeoutException when the wait fails.
  • .until(condition) — fire it.

Each call returns this, hence the chaining.

When FluentWait actually earns its keep

Three real cases:

1. Custom polling cadence

The default 500ms is reasonable but not always optimal:

// Slow polling — when the operation you're waiting for is genuinely expensive
// (e.g., a backend job that takes minutes; you don't want to hammer it every 500ms)
FluentWait<WebDriver> slow = new FluentWait<>(driver)
    .withTimeout(Duration.ofMinutes(5))
    .pollingEvery(Duration.ofSeconds(5));
 
// Fast polling — when you need sub-half-second responsiveness
// (e.g., racing an animation that finishes quickly)
FluentWait<WebDriver> fast = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(2))
    .pollingEvery(Duration.ofMillis(100));

In practice you rarely need either extreme. The 500ms default is well-chosen.

2. Ignoring multiple exception types

The most common reason to drop down to FluentWait. If your wait may throw both NoSuchElementException (element not yet in DOM) AND StaleElementReferenceException (element re-rendered mid-poll), a WebDriverWait ignores the first but propagates the second:

FluentWait<WebDriver> wait = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(10))
    .ignoring(NoSuchElementException.class)
    .ignoring(StaleElementReferenceException.class);
 
WebElement card = wait.until(driver -> {
    WebElement el = driver.findElement(By.cssSelector(".product-card"));
    if (el.getText().contains("In stock")) return el;
    return null;
});

This is exactly the pattern for waiting on a re-rendering SPA card to settle.

3. Custom failure messages

When a wait fails, the default TimeoutException message says something like "Expected condition failed: waiting for visibility of element located by ...". On a complex condition that's two lambdas deep, the message can be cryptic. withMessage(...) lets you include the business intent:

FluentWait<WebDriver> wait = new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(15))
    .withMessage(() -> "Order status didn't reach 'Confirmed' within 15s. Last status: "
        + driver.findElement(By.id("order-status")).getText());
 
wait.until(driver -> driver.findElement(By.id("order-status")).getText().equals("Confirmed"));

The lambda form (Supplier<String>) means the message is evaluated only when the wait fails — so reading the page state in the message doesn't slow the happy path. When the wait does fail, the next engineer sees what the page actually said. Worth its weight.

When NOT to use FluentWait

If all you're doing is wait.until(ExpectedConditions.visibilityOfElementLocated(...)), WebDriverWait is the better choice:

  • Less typing.
  • Identical behaviour.
  • Standard idiom — every Selenium reader recognises it instantly.

Reaching for FluentWait to wait for one element to appear is over-engineering. Save it for when you genuinely need one of the three powers above.

WebDriverWait vs FluentWait at a glance

Same job, different reach

WebDriverWait — your default

  • new WebDriverWait(driver, Duration.ofSeconds(10))

  • Polls every 500ms

  • Ignores NoSuchElementException by default

  • Generic message on timeout

  • One line per call site

  • Right answer for ~95% of waits

FluentWait — the configurable parent

  • new FluentWait<>(driver).withTimeout(...).pollingEvery(...)

  • Custom polling interval (any duration)

  • Stack multiple .ignoring(...) calls

  • Custom failure message via .withMessage(...)

  • Wait on any T, not just WebDriver actions

  • Reach for it when WebDriverWait isn't enough

A real test using FluentWait

A scenario WebDriverWait would handle awkwardly: poll a status field that re-renders constantly (so you'd hit StaleElementReferenceException mid-poll), with a custom failure message:

package com.mycompany.tests.tests;
 
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.FluentWait;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
 
import java.time.Duration;
import java.util.function.Function;
 
public class FluentWaitTest {
 
    WebDriver driver;
    FluentWait<WebDriver> resilient;
 
    @BeforeMethod
    public void setup() {
        WebDriverManager.chromedriver().setup();
        driver = new ChromeDriver();
 
        resilient = new FluentWait<>(driver)
            .withTimeout(Duration.ofSeconds(15))
            .pollingEvery(Duration.ofMillis(500))
            .ignoring(NoSuchElementException.class)
            .ignoring(StaleElementReferenceException.class)
            .withMessage(() -> "Inventory didn't load in 15s. Page title was: " + driver.getTitle());
 
        driver.get("https://www.saucedemo.com");
        driver.findElement(By.id("user-name")).sendKeys("standard_user");
        driver.findElement(By.id("password")).sendKeys("secret_sauce");
        driver.findElement(By.id("login-button")).click();
    }
 
    @Test
    public void shouldWaitForFirstProductWithCustomFluentWait() {
        Function<WebDriver, WebElement> firstProductReady = driver -> {
            WebElement el = driver.findElement(By.cssSelector("[data-test='inventory-item']"));
            return el.isDisplayed() ? el : null;
        };
 
        WebElement firstProduct = resilient.until(firstProductReady);
        Assert.assertTrue(firstProduct.isDisplayed());
    }
 
    @AfterMethod
    public void teardown() {
        if (driver != null) driver.quit();
    }
}

If the wait ever fails, the error message includes the page title — making the failure self-explanatory in CI logs.

The Selenium tool entry on qa.codes lists the full FluentWait API, and the TestNG cheat sheet covers the assertion conventions.

⚠️ Common mistakes

  • Defaulting to FluentWait when WebDriverWait is enough. Two lines vs five, identical outcome — and the two-liner is what the rest of your team expects to see. Only drop down to FluentWait when one of its three powers (polling cadence, multi-ignore, custom message) actually matters.
  • Polling too aggressively. pollingEvery(Duration.ofMillis(50)) looks responsive but hammers the browser 20× per second — adding more CPU than it saves wall-clock. The default 500ms is correct in almost every case. Don't tune it without a measurable reason.
  • Forgetting that .ignoring(...) only applies to exceptions thrown by the condition function, not the surrounding code. If the wait condition runs cleanly but findElement outside the condition throws, .ignoring(...) doesn't catch it. Make sure the call you want retried is inside the lambda.

🎯 Practice task

Build a real-world FluentWait. 25–35 minutes.

  1. Add FluentWaitTest from this lesson to your project. Run it; it should pass.
  2. Justify FluentWait by replacing it. Try to express the same behaviour with WebDriverWait alone — pass a function rather than a built-in ExpectedConditions value. You can do it; the only thing you lose is the second .ignoring(...) for StaleElementReferenceException. Stage a stale element by re-rendering the page mid-wait. Watch the WebDriverWait version fail intermittently while FluentWait survives.
  3. Custom message that matters. Modify the withMessage to include the URL, the title, and the visible text of the error banner if present:
    .withMessage(() -> {
        String banner = driver.findElements(By.cssSelector("[data-test='error']")).stream()
            .map(WebElement::getText).findFirst().orElse("(no banner)");
        return String.format("Wait failed at URL=%s, title=%s, banner=%s",
            driver.getCurrentUrl(), driver.getTitle(), banner);
    })
    Force a failure by waiting for an element that doesn't exist. Read the message — observe how much faster a future you can debug.
  4. Polling cadence experiment. Time how long it takes to detect a new element with pollingEvery(Duration.ofMillis(100)) vs Duration.ofMillis(500)). Use a tight loop that creates the element via JavascriptExecutor after a known delay. The 100ms case wins on detection latency by ~200ms but does many more polls. Decide whether that's worth it for your suite.
  5. Stretch: write a generic helper:
    public static <T> T pollUntil(WebDriver driver, Duration timeout,
                                  Function<WebDriver, T> condition, String description) {
        return new FluentWait<>(driver)
            .withTimeout(timeout)
            .pollingEvery(Duration.ofMillis(500))
            .ignoring(NoSuchElementException.class)
            .ignoring(StaleElementReferenceException.class)
            .withMessage(description)
            .until(condition);
    }
    Use it in two tests. The function-of-function-pointer-of-functions style is the kind of utility that ages well in a 200-test suite.

Next lesson: the catalogue of synchronisation issues — StaleElementReferenceException, ElementClickInterceptedException, dynamic content, SPA transitions, animations — and the wait pattern that solves each. The lesson where everything you've learned in this chapter comes together.

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