PRACTICAL GUIDE / FluentWait lazy timeout message supplier

Build timeout evidence only when the wait actually fails

Use FluentWait's message supplier to defer diagnostic formatting until timeout, preserve the last poll state, and avoid masking the original failure.

By The Testing AcademyUpdated August 4, 20269 min read
All field guides
In this guide6 sections
  1. Where the eager work really happens
  2. Carry the last observation out of the condition
  3. Decide what belongs in the snapshot
  4. Prove the supplier did not replace the failure
  5. Keep exception handling narrow
  6. When a lazy message is the wrong tool

What you will learn

  • Where the eager work really happens
  • Carry the last observation out of the condition
  • Decide what belongs in the snapshot
  • Prove the supplier did not replace the failure

Your wait succeeds in 300 milliseconds, yet every call spends two seconds building a screenshot-rich error message. The code passed withMessage(expensiveDiagnostics(driver)), so Java evaluated the method before polling began. A supplier moves that cost to the timeout path, but only if the supplier itself is safe.

Where the eager work really happens

FluentWait has two relevant overloads: withMessage(String) and withMessage(Supplier<String>). The first accepts an already constructed string. Java evaluates method arguments before invoking a method, so this code performs the diagnostic work immediately:

Java
new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(10))
    .withMessage(buildDiagnostics(driver));

Selenium did not make the call eager. Java produced the string before withMessage(String) received it. If buildDiagnostics() takes a screenshot, serializes a large DOM fragment, or sends WebDriver commands, every successful wait pays that cost.

The supplier overload changes when the message is evaluated:

Java
new FluentWait<>(driver)
    .withTimeout(Duration.ofSeconds(10))
    .withMessage(() -> "Checkout was not ready: " + lastObservation.get());

Selenium documents that supplier as being evaluated on failure and appended to the timeout message. It is not called on every poll, and it does not influence whether the condition succeeds.

The wait loop repeatedly applies the condition until it returns a value that is neither null nor false. An unignored exception stops the wait immediately. An ignored exception can be retained as the last failure while polling continues. Once the timeout expires, Selenium builds the timeout exception and asks the message supplier for its text.

WebDriverWait extends FluentWait<WebDriver>, so it inherits the supplier overload. It also ignores NotFoundException by default, whereas a newly constructed FluentWait ignores nothing unless configured. Moving code between the two classes without noticing that policy can change an immediate lookup failure into a timeout, even when the message code is identical.

Polling time matters here. The configured interval does not include the cost of evaluating the condition. A condition that spends 700 milliseconds on browser calls cannot truly poll every 100 milliseconds. Lazy message construction removes success-path message cost, but it cannot repair an expensive condition.

Carry the last observation out of the condition

The useful pattern is to collect a small snapshot from values the condition already needed, then let the supplier format that snapshot. Do not make the supplier revisit the page after the last failed poll.

This JUnit 5 example contains both paths. The first button becomes enabled before timeout, so the supplier is never evaluated. The second locator times out, and the resulting message includes the last observed match count and state.

Java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.FluentWait;

class LazyWaitMessageTest {
    private final WebDriver driver = new ChromeDriver();

    record Observation(
        long elapsedMillis,
        int matches,
        boolean displayed,
        boolean enabled
    ) {}

    @AfterEach
    void stopBrowser() {
        driver.quit();
    }

    @Test
    void evaluatesTheMessageOnlyOnTimeout() {
        String html = """
            <button id="place-order" disabled>Place order</button>
            <script>
              setTimeout(() => {
                document.getElementById('place-order').removeAttribute('disabled');
              }, 250);
            </script>
            """;
        String page = "data:text/html;base64," + Base64.getEncoder()
            .encodeToString(html.getBytes(StandardCharsets.UTF_8));
        driver.get(page);

        AtomicInteger successMessages = new AtomicInteger();
        AtomicReference<Observation> successState = new AtomicReference<>();
        WebElement button = waitForReady(
            By.id("place-order"),
            Duration.ofSeconds(2),
            successState,
            successMessages
        );

        assertEquals(0, successMessages.get());
        assertTrue(button.isEnabled());

        AtomicInteger timeoutMessages = new AtomicInteger();
        AtomicReference<Observation> timeoutState = new AtomicReference<>();
        TimeoutException failure = assertThrows(
            TimeoutException.class,
            () -> waitForReady(
                By.id("missing-button"),
                Duration.ofMillis(350),
                timeoutState,
                timeoutMessages
            )
        );

        assertEquals(1, timeoutMessages.get());
        assertTrue(failure.getMessage().contains("matches=0"));
    }

    private WebElement waitForReady(
        By locator,
        Duration timeout,
        AtomicReference<Observation> last,
        AtomicInteger messagesBuilt
    ) {
        long started = System.nanoTime();

        FluentWait<WebDriver> wait = new FluentWait<>(driver)
            .withTimeout(timeout)
            .pollingEvery(Duration.ofMillis(100))
            .ignoring(StaleElementReferenceException.class)
            .withMessage(() -> {
                messagesBuilt.incrementAndGet();
                return "Control did not become ready; locator=" + locator
                    + ", last=" + last.get();
            });

        return wait.until(current -> {
            List<WebElement> matches = current.findElements(locator);
            WebElement candidate = matches.size() == 1 ? matches.get(0) : null;
            boolean displayed = candidate != null && candidate.isDisplayed();
            boolean enabled = candidate != null && candidate.isEnabled();
            long elapsed = Duration.ofNanos(
                System.nanoTime() - started
            ).toMillis();

            last.set(new Observation(
                elapsed,
                matches.size(),
                displayed,
                enabled
            ));
            return displayed && enabled ? candidate : null;
        });
    }
}

The AtomicReference is a mutable holder for an immutable record. The wait and supplier run on the calling thread in this example, so an ordinary holder object could also work. Atomics make the handoff explicit, but they do not make FluentWait safe to share between test threads. Keep one wait and one driver owner per test flow.

The counter demonstrates laziness; production code does not need it. Avoid depending on a specific number of supplier invocations as business behavior. The useful contract is that successful waits do not need the timeout text, while a timeout can include the last snapshot.

Decide what belongs in the snapshot

A diagnostic snapshot should answer why the condition remained false. For a button, match count, displayed state, enabled state, elapsed time, and perhaps a stable application state attribute are usually enough. For a network-backed status panel, the latest visible status text or request correlation ID may matter more.

Reuse values already collected by the condition. Calling findElements() once and recording its size is cheap relative to calling it again only for logging. If the condition reads a status attribute to decide readiness, store that value in the same poll.

Do not turn every poll into an artifact dump. Screenshots, page source, accessibility trees, and large JavaScript objects multiply wait cost by the poll count. A ten-second wait at 100-millisecond intervals can run the condition roughly one hundred times when its work is fast. Even a modest extra command becomes material on a remote Grid.

Sensitive data also belongs outside messages. Selenium exceptions flow into CI logs, test reports, chat notifications, and retained artifacts. Record only the state needed for diagnosis, redact tokens and customer values, and cap user-visible text rather than embedding full DOM.

There is a trade-off between precision and overhead. A lightweight snapshot is slightly less complete than a timeout-time page dump, but it represents the state observed by the condition and cannot issue a new browser command that changes the failure. Capture heavier artifacts once in the catch block or the test framework's failure hook.

Choose snapshot fields per condition rather than building a universal object. A universal snapshot tends to query URL, title, DOM, network state, and screenshots whether the wait concerns any of them. A small condition-specific record is faster, easier to redact, and clearer in a timeout. The cost is several tiny record types, which is preferable to one expensive diagnostic collector hidden behind every wait.

That later screenshot is not perfectly simultaneous with the last poll. Name it as timeout-time evidence, not final-poll evidence. The snapshot holds the causal condition state; the screenshot supplies visual context.

Prove the supplier did not replace the failure

Run the focused test and inspect the full TimeoutException:

Shell
mvn -Dtest=LazyWaitMessageTest#evaluatesTheMessageOnlyOnTimeout \
  -DtrimStackTrace=false test

The custom text should appear alongside Selenium's attempted timeout and polling interval. If the condition threw an ignored exception on its final attempts, inspect the cause chain as well. That exception may explain a re-render or transient lookup failure better than the Boolean snapshot alone.

A supplier that calls WebDriver creates a second failure boundary:

Java
.withMessage(() -> "URL=" + driver.getCurrentUrl()
    + ", source=" + driver.getPageSource())

At timeout, the session may already be invalid, an alert may block commands, or navigation may be in progress. If the supplier throws, that new exception can prevent the intended timeout diagnostic from being constructed. Even when it succeeds, it observes the page after the final condition evaluation, not necessarily the state that made the condition false.

Test the timeout path deliberately. Many teams test only the success case, so broken suppliers remain dormant until CI is already failing. A small unit or browser test with a short timeout should verify the custom text, last observation, cause preservation, and artifact hook behavior.

Also verify the success path does not execute the supplier. Use a counter around genuinely expensive formatting in a focused test, then remove the counter from production. Measuring suite duration alone can miss the issue because Grid startup and navigation noise are much larger.

Keep exception handling narrow

Ignoring StaleElementReferenceException can be reasonable when the component is known to replace the candidate during rendering and the condition relocates it on every poll. It has a cost: repeated staleness becomes a timeout instead of surfacing at the first occurrence. The last observation and retained cause must make that behavior visible.

Do not ignore WebDriverException broadly. That family includes failures such as an invalid session or browser communication problem that another poll cannot heal. Converting them into a ten-second timeout delays the result and mislabels infrastructure failure as application readiness.

findElements() is useful for readiness conditions because zero matches returns an empty list instead of throwing NoSuchElementException. This lets the snapshot record matches=0 without adding another ignored exception. More than one match should usually remain false and be reported, because silently choosing the first makes ambiguity look like readiness.

An unignored exception exits immediately, so the timeout supplier is not evaluated. That is correct. The custom timeout message describes a condition that stayed unsatisfied until its deadline, not every possible way the wait call can fail.

When a lazy message is the wrong tool

Use the String overload for a cheap, static statement such as "Checkout button did not become enabled". A supplier adds indirection without value when there is no deferred work or changing snapshot.

Do not use the message supplier as a per-poll logger. It runs on the timeout path, not as an observer. If every transition matters, instrument the condition deliberately with bounded structured records and accept the runtime and storage cost.

Avoid embedding recovery actions in the supplier. Refreshing the page, dismissing an alert, or closing a modal while constructing an exception changes the system after the wait has failed. Recovery belongs in an explicit higher-level flow where its side effects and retry policy can be tested.

Do not move all diagnostics out of the condition if doing so removes the state needed to understand readiness. The goal is not zero work per poll; the goal is no duplicate or unrelated work. Collect the values the decision already requires and defer only their human-readable formatting.

Finally, do not add an elaborate supplier to compensate for a vague condition. until(driver -> isReady()) with no observable state will still produce a mysterious timeout. Name the readiness contract, record its last relevant values, and let the message explain the unmet condition in one line.

// 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
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

When does FluentWait evaluate a Supplier<String> message?

The supplier is evaluated when the wait times out, so a successful wait does not build that message. Selenium appends the supplied text to its timeout failure rather than using it as a polling condition.

What is the difference between withMessage(String) and withMessage(Supplier<String>)?

Java evaluates a String argument before withMessage() is called, including any method used to construct it. The Supplier overload stores work that can be performed later on the failure path.

Should a timeout message supplier call WebDriver?

Avoid additional browser commands there. A failed session, alert, navigation, or supplier exception can replace the useful TimeoutException, while the page may already differ from the final observation.

How can a timeout message include the last state Selenium saw?

Update a small immutable snapshot inside the wait condition using values the poll already collected. Let the supplier format that snapshot without querying the browser again.

Which exceptions should FluentWait ignore while polling?

Ignore only exceptions that are expected to be transient for that condition, such as a narrowly understood stale element during re-render. Connection failures, invalid sessions, and broken selectors should usually stop immediately.