PRACTICAL GUIDE / Java Supplier lazy Selenium element lookup

Use Java Supplier without hiding stale Selenium elements

Use Java Supplier to defer Selenium element lookup, reproduce a stale reference after rerender, and decide when a plain By or explicit wait is clearer.

By The Testing AcademyUpdated August 4, 20269 min read
All field guides
In this guide6 sections
  1. Why an element becomes stale after rerendering
  2. Reproduce the stale reference and the fresh lookup
  3. Confirm the stale reference is the real failure
  4. Choose the smallest abstraction that stays fresh
  5. Pay for freshness only where it helps
  6. Do not wrap every element in Supplier

What you will learn

  • Why an element becomes stale after rerendering
  • Reproduce the stale reference and the fresh lookup
  • Confirm the stale reference is the real failure
  • Choose the smallest abstraction that stays fresh

A page object stores a Save button during construction. The application rerenders the form, the new button looks identical, and the next click throws StaleElementReferenceException. The locator is still correct, but the stored element belongs to the old DOM.

A Java Supplier can defer that lookup until the action needs it, but used carelessly it only wraps the same stale reference in a lambda and makes the extra indirection harder to see.

Why an element becomes stale after rerendering

findElement does not return a live query. WebDriver returns an element reference associated with a particular node in the current document and browsing context. Selenium keeps that remote identity inside the WebElement so later calls such as click() and getText() can target the same node.

Modern UI frameworks often replace nodes instead of updating them in place. A button with id="save" can disappear and a new button with the same ID, text, and CSS classes can take its position. To a person, it is still the Save button. To WebDriver, the old reference points to a node that is no longer attached, so an action on it is stale.

Navigation destroys the old document and has the same effect. Switching windows or frames creates a different problem: the reference may belong to a valid element in another context, but it is not usable from the current one. Repeating findElement in the wrong frame does not repair that context error.

Supplier<T> has one relevant operation, get(). A lambda such as () -> driver.findElement(saveButton) delays the command until get() runs. Each invocation executes findElement again and can return the element currently matched by the locator.

This version is lazy but not fresh:

Java
WebElement button = driver.findElement(By.id("save"));
Supplier<WebElement> wrong = () -> button;

The lambda captures an element that was already resolved. Calling wrong.get() ten times returns the same reference ten times. The correct closure captures the recipe for finding the element:

Java
By saveButton = By.id("save");
Supplier<WebElement> currentSaveButton =
    () -> driver.findElement(saveButton);

The Supplier adds no waiting, retrying, caching, or thread safety. It simply decides when code runs. Those omissions are useful because they keep policy separate, but only if the framework says so plainly.

Exceptions from findElement also pass straight through get(). The caller still needs to distinguish an element that is not ready from an invalid selector, dead session, or wrong context. Do not bury a broad catch inside the lambda. A name such as currentSaveButton also communicates the fresh-lookup behavior better than a field named saveButton, which readers may reasonably assume is a stored element.

Reproduce the stale reference and the fresh lookup

The following JUnit 5 test uses a self-contained data URL. Clicking Rerender synchronously replaces the Save button with a clone. The cached reference fails, while the Supplier locates the replacement.

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

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;

class LazyElementLookupTest {
  private WebDriver driver;

  @BeforeEach
  void openBrowser() {
    driver = new ChromeDriver();
  }

  @AfterEach
  void closeBrowser() {
    if (driver != null) {
      driver.quit();
    }
  }

  @Test
  void supplierFindsTheReplacementNode() {
    String html = """
        <!doctype html>
        <html lang="en">
          <body>
            <button id="save"
                    onclick="this.dataset.state='clicked'">Save v1</button>
            <button id="rerender" onclick="replaceSave()">Rerender</button>
            <script>
              function replaceSave() {
                const oldButton = document.getElementById('save');
                const newButton = oldButton.cloneNode(true);
                newButton.textContent = 'Save v2';
                oldButton.replaceWith(newButton);
              }
            </script>
          </body>
        </html>
        """;

    String encoded = Base64.getEncoder().encodeToString(
        html.getBytes(StandardCharsets.UTF_8));
    driver.get("data:text/html;base64," + encoded);

    By saveLocator = By.id("save");
    WebElement cachedSave = driver.findElement(saveLocator);
    AtomicInteger lookupCount = new AtomicInteger();

    Supplier<WebElement> currentSave = () -> {
      lookupCount.incrementAndGet();
      return driver.findElement(saveLocator);
    };

    driver.findElement(By.id("rerender")).click();

    assertThrows(
        StaleElementReferenceException.class,
        cachedSave::click);

    WebElement replacement = currentSave.get();
    assertEquals("Save v2", replacement.getText());
    replacement.click();

    assertEquals(
        "clicked",
        currentSave.get().getAttribute("data-state"));
    assertEquals(2, lookupCount.get());
  }
}

Run the class on its own:

Shell
mvn -Dtest=LazyElementLookupTest -DtrimStackTrace=false test

The example deliberately counts lookups. Lazy abstractions can hide remote calls, especially on Grid. A counter or command listener in framework tests makes that cost visible.

Notice that the test stores the first fresh result in replacement before reading and clicking it. Code such as currentSave.get().isDisplayed(); currentSave.get().click(); performs two separate lookups. The DOM can change between them, and the second lookup may target a different node from the one whose state was checked.

Confirm the stale reference is the real failure

Read the first exception, not the final retry result. A genuine stale-element stack trace appears when an operation uses a previously found WebElement. Record the locator, the action, and when the original lookup occurred. Without that timeline, a framework may blame a rerender that happened after the actual failure.

Reproduce with a focused command and no automatic test retry. Add a log at every Supplier invocation. If the failure happens without a new invocation, some code path is still using a cached element. If a new lookup succeeds and the subsequent action goes stale, the page is changing during the action window and Supplier alone cannot close that race.

Inspect the DOM transition in the browser. A framework may update the existing node's text, which does not necessarily invalidate the reference, or replace the node completely, which does. Break on subtree modifications in DevTools or log a stable application event around the rerender. A before-and-after screenshot cannot show node identity, so it is supporting evidence rather than proof.

Check the current window and frame next. A supplier that captures driver searches from whatever context that driver currently has. If another method switched into an iframe and never switched back, a fresh lookup can fail with NoSuchElementException even though the locator is valid in the top-level document. Restore the intended context before relocating.

Navigation deserves separate treatment too. If the test is unexpectedly on a login or error page, repeatedly finding By.id("save") is not resilience. Assert the page identity or URL at the boundary where navigation should complete, then look up the control.

Finally, compare the remote command log with source code. Every get() is normally a new find-element command. If one page action calls the supplier five times, the abstraction is adding traffic and widening the number of moments at which the DOM can change.

Choose the smallest abstraction that stays fresh

Storing By is often enough. A page method can call driver.findElement(saveLocator) immediately before the action. Reviewers see the lookup, there is no general-purpose functional wrapper, and Selenium's existing expected conditions work naturally with locators.

A Supplier is useful when code needs to pass deferred lookup behavior across a boundary. A component might accept Supplier<WebElement> because it must fetch the current root each time it performs an operation, while the page object retains ownership of the driver and locator. That is a concrete reason for the functional interface, not a style preference.

Keep readiness policy outside a bare Supplier. When the element can be late, an explicit wait can locate by By and return the ready element:

Java
WebElement readySave = new org.openqa.selenium.support.ui.WebDriverWait(
    driver, java.time.Duration.ofSeconds(5))
    .until(org.openqa.selenium.support.ui.ExpectedConditions
        .elementToBeClickable(By.id("save")));

readySave.click();

This is clearer than teaching every Supplier to sleep or swallow exceptions. A DOM replacement can still occur after the condition succeeds and before click(), so investigate frequent races instead of adding unlimited retries.

If the framework retries a stale action, bound the retry and prove the action's outcome. A second click may hit a replacement button after the first click already reached the application. For non-idempotent actions such as payment, submission, or deletion, blind action retries can duplicate real work.

Pay for freshness only where it helps

Each fresh lookup is a WebDriver command. That cost is modest in a local browser and more visible on a remote Grid. Repeated get() calls in fluent assertion helpers can make a simple test chatty. Resolve once per logical check, and resolve again only after a known transition or wait boundary.

Freshness also changes identity semantics. If a list is reordered, the same locator may now match a different row. Relocating avoids a stale exception but can make the test act on the wrong entity. Include a stable record key in the locator and assert the row's identifying value before a destructive action.

The captured objects have lifetimes. A Supplier held in a static field can outlive the driver session it closed over. In parallel execution, sharing that Supplier can direct one test's action to another test's browser or to a session that has already quit. Keep deferred elements inside the page or component instance owned by one test.

Nested search contexts need the same care. A Supplier that captures a previously found container element can still become stale even if it finds the child lazily. Capture a locator chain or reacquire the container before searching within it.

Do not wrap every element in Supplier

Skip the abstraction for elements used once immediately after lookup. It adds a type and an invocation without changing timing. A local WebElement is fine when no navigation or rerender can occur between lookup and action.

Avoid Supplier as a blanket cure for poor synchronization. If the application is still loading, wait for the user-observable ready state. Repeated instant lookups merely fail closer to the action and can produce a different flaky exception.

Do not use fresh lookup when the test specifically needs to prove that the same DOM node persists. Replacing the node may itself be the defect, for example when focus, selection, or assistive-technology state must survive an update. A Supplier would silently follow the replacement and erase that evidence.

Most page objects need stable locators, explicit state transitions, and methods that look up elements near their use. Add Supplier<WebElement> only when deferred behavior crosses a real design boundary. The goal is not to prevent Selenium from ever saying "stale." The goal is to know when a replacement is expected, fetch the current node at that point, and keep unexpected replacements visible.

// 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
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

What does Supplier WebElement do in Selenium?

A `Supplier<WebElement>` can defer `findElement` until `get()` is called. It provides a fresh lookup only when the lambda captures a locator and search context rather than an element that was already found.

Does a lazy element lookup prevent every stale element error?

No. The DOM can still replace an element between the fresh lookup and the next action, and a changed frame or page can make the locator invalid for the current context.

Is it better to store By or Supplier WebElement in a page object?

Storing `By` is simpler when methods can call `driver.findElement(locator)` directly. A Supplier earns its place when another component needs an executable lookup without taking ownership of the driver.

Can I combine Supplier with WebDriverWait?

Yes, a wait condition can call the supplier and return the element when the required state is present. In many cases, Selenium's existing expected conditions accept a `By` and express the same intent more clearly.

Is Supplier WebElement safe across parallel tests?

Not by itself. Thread safety and session ownership come from the captured driver and framework design; sharing a supplier that closes over one driver can send commands to the wrong session.