PRACTICAL GUIDE / Selenium PageFactory CacheLookup stale element
Why Selenium PageFactory CacheLookup Goes Stale
Fix Selenium PageFactory CacheLookup stale element failures by tracing lazy proxies, React replacement, safe caching, and reliable re-location.
In this guide11 sections
- Why Does CacheLookup Cause a Stale Element?
- What Does PageFactory Do Without CacheLookup?
- What Changes When You Add @CacheLookup?
- Diagnose a Cached Stale Element: A Numbered Workflow
- Reproduce the Failure with CartPage.java
- How Should Dynamic Page Objects Re-Locate Elements?
- When Is @CacheLookup Safe to Use?
- Why Does Catching StaleElementReferenceException Not Fix the Design?
- Which PageFactory Annotation Mistakes Look Similar?
- Frequently Asked Questions About CacheLookup and Stale Elements
- Does PageFactory cache elements by default?
- Why does @CacheLookup work before a React re-render?
- Will an explicit wait refresh a cached WebElement?
- Should StaleElementReferenceException always be retried?
- When is @CacheLookup appropriate?
- Is PageFactory itself incompatible with React?
- Should dynamic page objects use By fields instead of WebElement fields?
- Next Steps
What you will learn
- Why Does CacheLookup Cause a Stale Element?
- What Does PageFactory Do Without CacheLookup?
- What Changes When You Add @CacheLookup?
- Diagnose a Cached Stale Element: A Numbered Workflow
@CacheLookup tells PageFactory to keep the first located WebElement. If React or another UI framework replaces that DOM node, the cached reference points to a detached node and subsequent calls throw StaleElementReferenceException. Remove @CacheLookup from dynamic elements so the PageFactory proxy performs a fresh lookup for each method call, then wait for the updated condition rather than retrying a dead reference.
A Selenium PageFactory CacheLookup stale element failure has a recognizable sequence: the first read succeeds, a UI action updates the component, and the next read through the same field fails. The selector may still be valid. The visible badge may still be present. The failure is about the identity and lifetime of the stored DOM reference, not whether matching markup exists now.
Why Does CacheLookup Cause a Stale Element?
WebDriver does not send a CSS selector with every command performed on an already located WebElement. A find command first returns an element reference associated with one DOM node. Later calls such as getText(), click(), or isDisplayed() use that reference. The W3C WebDriver stale element definition says an element is stale when its node is no longer connected or belongs to a document that is no longer active.
@CacheLookup asks the PageFactory locator to remember the element found on first use. The official CacheLookup API describes the annotation for a WebElement that never changes, specifically where the same instance in the DOM remains in use. That is a stronger promise than "the same locator keeps matching."
Suppose [data-test='cart-count'] matches a badge showing 1. A component update can take either of two broad paths. It may change the text on the existing node, in which case the reference can remain valid. Or it may remove that node and insert a new badge showing 2, in which case the old reference is detached. React can take either path based on component identity and rendering behavior. Other frameworks and plain JavaScript can replace nodes too.
The diagnostic signature matters. If the locator never matched, expect a location failure. If an overlay receives a click, investigate hit testing with ElementClickInterceptedException diagnostics. If the first badge read passes and a later read fails immediately after replacement, inspect caching and reference ownership. The broader React stale element debugging guide covers replacement triggers; this article stays focused on the PageFactory caching contract.
What Does PageFactory Do Without CacheLookup?
PageFactory.initElements decorates eligible WebElement and List<WebElement> fields with lazy proxies. Initialization installs the proxy; it does not necessarily locate every field at constructor time. The PageFactory Java API states that, by default, an element or list is looked up each time a method is called on it.
Consider an uncached field:
@FindBy(css = "[data-test='cart-count']")
private WebElement cartBadge;
public CartPage(WebDriver driver) {
PageFactory.initElements(driver, this);
}
public int badgeCount() {
return Integer.parseInt(cartBadge.getText());
}The cartBadge variable contains a proxy. Calling badgeCount() reaches cartBadge.getText(), which causes the default locator to find the current matching element and then invoke getText() on it. If a previous badge node was replaced before this call, the new lookup can resolve the current badge.
This behavior does not make every compound operation atomic. A page method that locates an element, performs several commands, and crosses an asynchronous update between those commands can still encounter staleness. A list can also change between separate list operations. The useful guarantee is narrower: the default proxy performs a fresh lookup for each method invocation on the proxy.
That distinction is why the page-object design belongs with framework tradeoffs. The repository roadmap module selenium-framework-patterns places "Page Factory and its tradeoffs" under separating test intent, browser actions, data, and infrastructure. Caching is therefore a deliberate lifecycle decision, not a decoration to add by habit. For wider ownership patterns, see the Selenium page component model guide and Selenium Java framework design scenarios.
What Changes When You Add @CacheLookup?
The first method call on a cached PageFactory field still needs a lookup. After that lookup succeeds, subsequent calls use the retained WebElement. A fresh findElement is not performed for each proxy method. That saves repeated lookup commands only while accepting responsibility for the reference's lifetime.
| Field or method design | Lookup timing | Tolerance of DOM replacement | Command cost | Suitable element |
|---|---|---|---|---|
Uncached @FindBy WebElement | Lazy lookup for each proxy method call | Next call can find the replacement | Repeats the lookup | Dynamic field with a stable locator |
@FindBy plus @CacheLookup | Lazy first lookup, then retain that element | Retained reference becomes stale after replacement | Avoids later lookup commands | Proven same DOM instance for the required lifetime |
Explicit By in a page method | driver.findElement or locator-based wait where the method specifies | Each explicit lookup can find the replacement | Visible and controllable in code | Dynamic workflow needing clear wait and retry scope |
Do not interpret "command cost" as proof that caching improves a suite. A locator call has a cost, but the practical effect depends on remote latency, access frequency, locator behavior, page behavior, and the surrounding test. Measure before accepting a reference-lifetime risk. An annotation named like an optimization is still a correctness contract.
Also distinguish replacement from mutation. If the application changes only the existing node's text, attributes, or descendants while keeping that node connected, a cached reference may continue to work. If navigation changes the active document, a retained element from the old document becomes stale even if the next page contains identical markup. The WebElement Java API notes that element calls perform freshness checks and future calls to an invalid instance fail.
A useful code review question is not "Does this element look static?" Ask, "What evidence shows that this exact DOM instance persists across every action that can occur while this page object lives?" That wording exposes remounts, route transitions, responsive variants, locale changes, and component refreshes that a screenshot cannot reveal.
Diagnose a Cached Stale Element: A Numbered Workflow
Treat the exception as evidence about a reference boundary. Avoid changing timeouts until the trace shows which lookup produced the failing reference and which action may have detached it.
-
Identify the update boundary. Record the last successful page method, the user or API action that follows, and the first stale call. Include navigation, frame switches, modal changes, list refreshes, and component updates. "Fails sometimes" is not enough; seek the transition after which failure becomes possible.
-
Capture the first success. Run the smallest scenario that reads the field once before the update. Preserve the selector, returned text, current URL, window handle, and frame path. A successful first call proves that initial location can work, but it does not prove later reference validity.
-
Confirm node replacement rather than assuming it. Use browser developer tools, a DOM breakpoint, an application render trace, or a narrowly scoped
MutationObserverin a diagnostic environment. Determine whether the target node is removed and another matching node inserted. Do not infer replacement only because text changed. -
Find the reference owner. Inspect the page field for
@CacheLookup, a storedWebElement, a collection retained across updates, or a helper that captures an element in a closure. Check customFieldDecoratorandElementLocatorFactoryimplementations too. The field declaration may not be the only caching layer. -
Remove only
@CacheLookupand rerun. Keep the selector, action sequence, browser, and assertion unchanged. If the failure disappears and the trace now shows a lookup after the update, the experiment supports the cache-lifetime diagnosis. Avoid mixing locator rewrites and longer waits into this proof run. -
Wait for the new state through re-location. Use a locator-based condition or an uncached proxy method whose polling performs a new lookup. Bound the wait and assert a business-relevant state such as badge text
2, not merely the presence of any badge. -
Compare traces and preserve the conclusion. Document the old field contract, the replacement boundary, the fresh lookup, and the final condition. Add a regression test that crosses the update. This prevents a future "performance cleanup" from restoring the same cache without evidence.
The workflow separates freshness from readiness. Removing the cache lets the code address the current node; it does not guarantee that the node already shows the expected state. A wait handles readiness after freshness is corrected. Selenium wait command guidance explains the polling options, while implicit versus explicit waits helps keep their responsibilities distinct.
Reproduce the Failure with CartPage.java
The repository's TypeScript challenge seed embeds a teaching artifact titled CartPage.java. It is learning evidence, not a claim that src/db/seed-data/challenges/expansion-java.ts is production Java. The artifact uses the exact symbols CartPage, cartBadge, badgeCount(), PageFactory.initElements, @FindBy, and @CacheLookup to preserve a useful failure signature.
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.CacheLookup;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
public final class CartPage {
@FindBy(css = "[data-test='cart-count']")
@CacheLookup
private WebElement cartBadge;
public CartPage(WebDriver driver) {
PageFactory.initElements(driver, this);
}
public int badgeCount() {
return Integer.parseInt(cartBadge.getText());
}
}The first badgeCount() call locates the badge and stores the returned element. The test then adds another item. In the seeded scenario, that update replaces the badge node. The second badgeCount() call sends getText() through the same cached element, and its freshness check fails.
This example is intentionally specific. It does not show that every cart implementation replaces badges, that every React render replaces a node, or that PageFactory cannot work with React. It shows one proven update boundary and one field whose annotation promises a longer DOM lifetime than the application provides.
Create a regression that makes the boundary visible: read 1, trigger the second add, wait for a freshly located badge to show 2, then read the page-object value. If the test only loads the page and reads once, it cannot exercise the broken contract. This is also a good interview example because it separates selector validity, node identity, and state readiness. For more exercises at that level, use the WebElement state and interaction scenarios.
How Should Dynamic Page Objects Re-Locate Elements?
The smallest repair is to remove @CacheLookup from a dynamic @FindBy field. The existing page method can retain its readable API while PageFactory resolves the current node on each proxy method call. Keep that version when the team understands the proxy behavior and the method needs only a simple interaction.
An explicit By field is useful when a method needs a locator-based wait, several related lookups, or code review clarity about when location occurs:
import java.time.Duration;
import org.openqa.selenium.By;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
public final class CartPage {
private final WebDriver driver;
private final By cartBadge = By.cssSelector("[data-test='cart-count']");
public CartPage(WebDriver driver) {
this.driver = driver;
}
public int waitForBadgeCount(int expected) {
String expectedText = Integer.toString(expected);
String actualText = new WebDriverWait(driver, Duration.ofSeconds(5))
.ignoring(StaleElementReferenceException.class)
.until(currentDriver -> {
String text = currentDriver.findElement(cartBadge).getText().trim();
return expectedText.equals(text) ? text : null;
});
return Integer.parseInt(actualText);
}
}Every poll calls findElement(cartBadge), so it can observe a replacement node. The ignored stale exception is limited to this known race and this wait's deadline; the next poll starts with another lookup. The five-second boundary is an example, not a recommended universal timeout. Select a timeout from the application's expected behavior and the suite's failure budget. Return null while the condition is unmet, and let unrelated exceptions remain visible.
Do not locate once outside the wait and then close over that WebElement. This code is still unsafe:
WebElement badge = driver.findElement(cartBadge);
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ignored -> badge.getText().trim().equals("2"));The wait repeats getText() on the same reference. It changes polling duration, not reference freshness. The same problem applies when a wait receives a cached PageFactory field and the condition repeatedly interrogates it. Use the locator or the uncached proxy inside the polling function.
Conditions should express the state the test requires. Waiting only for presence can finish on a current node that still shows the old value. Waiting only for staleness proves the old reference detached but not that the replacement is ready. A condition that re-locates and checks text connects both facts. If the condition represents a reusable product rule, custom expected conditions for domain state shows how to keep that rule named and testable.
When Is @CacheLookup Safe to Use?
@CacheLookup can be appropriate, but the evidence bar should match its promise. First, confirm that the same DOM instance remains connected across the full page-object lifetime in scope. Second, confirm that repeated access is common enough for avoiding lookups to matter. Third, measure the relevant command path before describing caching as an optimization. Finally, add a test that crosses every update or navigation boundary the field is expected to survive.
Static appearance is weak evidence. A header can remount during responsive layout changes. A footer can be replaced during route transitions. A server-rendered shell can hand control to client code that rebuilds part of the page. A selector that matches on every screen says nothing about node identity.
Short-lived objects do not automatically make caching safe either. A component object may be created before an action that replaces its root. Conversely, a long-lived field can remain valid if the application truly preserves the same instance. Base the decision on observed DOM lifetime, not class lifetime alone.
When evidence is unavailable, prefer fresh location. The extra find command is visible and diagnosable; a hidden lifetime promise can fail far from the declaration that created it. Record any accepted cache with a comment naming the persistence boundary and the measurement that justified it. Framework reviews should also ask whether the page object survives navigation, whether parallel tests share instances, and whether custom decorators alter lookup behavior. The framework modernization interview guide provides a wider lens for reviewing such inherited conventions.
Why Does Catching StaleElementReferenceException Not Fix the Design?
The WebElement API makes the key behavior explicit: once the freshness check fails for that instance, future calls to the same instance fail. Catching StaleElementReferenceException and invoking cartBadge.getText() again does not help when cartBadge is backed by the same cached element. The retry has no fresh lookup from which to obtain a different reference.
An unbounded global retry is worse. It can consume time while hiding navigation to the wrong page, a missing frame switch, a locator that now targets two elements, or an application loop that keeps replacing content. It also moves recovery away from the page method that knows which update is expected.
A sound retry has three properties: it is bounded, it re-locates on every attempt, and it checks a specific post-update condition. The explicit By wait above satisfies those properties. An uncached proxy used inside an equally narrow condition can satisfy them too. If a new node keeps disappearing, let the bounded wait fail with evidence rather than concealing the instability.
Capture the original exception, update action, selector, current URL, context, and final wait outcome. Do not replace the first meaningful failure with an artifact-collection error. The goal is not to make stale exceptions invisible. It is to ensure that expected replacement is handled locally while unexpected replacement remains reviewable.
Which PageFactory Annotation Mistakes Look Similar?
The same teaching artifact includes @FindBys and @FindAll as an adjacent warning. They affect locator composition, not caching, so keep them separate during diagnosis.
| Annotation | Meaning in this context | Typical mistake | Result to investigate |
|---|---|---|---|
@FindBy | Defines how a field is located | Assuming it locates immediately during initialization | Failure timing is misunderstood |
@CacheLookup | Retains the first resolved element | Applying it to a replaceable node | First call passes, later call is stale |
@FindBys | Chains locators, each under the previous result | Reading the entries as alternatives | Empty or unexpectedly narrow results |
@FindAll | Finds elements matching any supplied locator | Expecting nested chain semantics | Broader union than intended |
In the seed scenario, @FindBys combines .add-to-cart followed by .buy-now. That asks for the second match within the first, not "either class." @FindAll expresses the union. Changing those annotations can fix an empty list, but it cannot refresh a cached badge. Removing @CacheLookup can fix badge freshness, but it cannot correct chain semantics.
Keep one hypothesis per experiment. For an empty collection, inspect locator composition and DOM structure. For a stale element, inspect reference ownership and replacement. For a wrong geometric target, remember that locator correctness does not prove safe interaction; Selenium relative locator geometry pitfalls provides that separate diagnostic model.
Frequently Asked Questions About CacheLookup and Stale Elements
Does PageFactory cache elements by default?
No. Standard PageFactory behavior installs a lazy proxy and performs location for each method call on the proxied field. @CacheLookup changes that field's contract. If your suite uses a custom decorator or locator factory, inspect it before relying on the standard behavior because framework code can introduce its own caching.
Why does @CacheLookup work before a React re-render?
Before the update, the first lookup returns a connected node, so caching it is immediately successful. A later render may preserve that node or replace it. Only the replacement path makes the retained reference stale. The same CSS selector can match the new node without changing the identity stored in the old WebElement.
Will an explicit wait refresh a cached WebElement?
Not by itself. A wait controls repeated evaluation and its deadline. If every poll calls the same cached element, every poll repeats the same invalid reference. Put a By lookup or an uncached proxy method inside the condition so each evaluation can address the current DOM.
Should StaleElementReferenceException always be retried?
No. Retry when replacement is an understood part of the workflow, and only when the retry performs a fresh lookup. Bound it by a meaningful condition and deadline. A generic catch around all element commands can hide wrong contexts, navigation errors, unstable application behavior, and genuinely broken locators.
When is @CacheLookup appropriate?
Use it when tests and DOM evidence show that the exact element instance persists for the required lifetime, repeated access makes lookup cost relevant, and measurements justify the trade. Revisit the decision when the application changes rendering, navigation, personalization, responsive behavior, or component ownership.
Is PageFactory itself incompatible with React?
No. PageFactory's default proxy can re-locate on each method call, which is useful after node replacement. React can also update a node without replacing it. The conflict is between caching a particular reference and an application boundary that detaches that reference, not between the two technologies in every use.
Should dynamic page objects use By fields instead of WebElement fields?
Either can work. By fields make lookup and locator-based waits explicit. Uncached PageFactory fields keep concise page methods while still re-locating at proxy method boundaries. Choose based on team readability, required operations, and verified behavior. Do not mix conventions in ways that obscure which layer owns freshness.
Next Steps
Start with the smallest failing sequence: read cartBadge, trigger the update, and read it again. Prove whether the node was replaced. Remove @CacheLookup from that dynamic field, then wait through a locator or uncached proxy for the exact updated state. Keep the selector unchanged during the proof so the caching hypothesis remains isolated.
Once the regression passes, review other cached fields by DOM lifetime rather than visual permanence. Record justified exceptions, remove speculative caches, and keep annotation composition findings separate from stale-reference findings. The result is a page object whose lookup policy matches the application's real update boundaries and whose failures point to a specific, inspectable contract.
// 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.
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.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official w3c.github.io reference
w3c.github.io
Primary documentation selected and verified for the claims in this guide.
- 04Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Does PageFactory cache elements by default?
No. PageFactory normally installs a lazy proxy for an eligible WebElement field, and the default locator runs again whenever a method is called on that proxy. Adding @CacheLookup changes that behavior by retaining the first resolved element. Custom field decorators or locator factories can define different behavior, so inspect framework extensions too.
Why does @CacheLookup work before a React re-render?
The first interaction can locate the current DOM node and cache its WebElement reference successfully. A later React update may replace that node while preserving the same selector and appearance. The stored reference then identifies the detached old node, so the next command fails even though a fresh search would find the replacement.
Will an explicit wait refresh a cached WebElement?
Not when the wait repeatedly evaluates the same cached WebElement. A timeout only bounds polling; it does not change what is polled. Use a locator-based condition, an uncached PageFactory proxy, or a lambda that performs driver.findElement on each poll. Then every attempt can observe the current DOM node.
Should StaleElementReferenceException always be retried?
No. Retry only when a known update boundary can replace the target and each attempt performs a fresh lookup. Bound the retry by time and a specific expected condition. Repeating a command on the same cached reference cannot restore it, while broad automatic retries can hide navigation, frame, locator, and application defects.
When is @CacheLookup appropriate?
Use it only after evidence shows that the same DOM element instance persists for the page object's relevant lifetime, repeated access is common, and lookup cost matters enough to justify the lifecycle risk. Static appearance is insufficient. Navigation, component remounting, responsive layout changes, and personalization can still replace an apparently permanent element.
Is PageFactory itself incompatible with React?
No. React may update text in an existing node or replace a node, depending on component identity and rendering decisions. An uncached PageFactory proxy can re-locate before each element method call. The risky combination is a retained WebElement reference plus a replacement boundary, not React and PageFactory as product categories.
Should dynamic page objects use By fields instead of WebElement fields?
A By field can make re-location and wait behavior explicit, which is useful for dynamic components and code review. An uncached PageFactory WebElement proxy can also re-locate correctly. Choose one convention consistently, keep browser actions inside page methods, and test the page object's behavior across the actual update boundaries it must survive.
RELATED GUIDES
Continue the learning route
GUIDE 01
Debug StaleElementReferenceException During React Re-Renders
Diagnose stale React element references by locating the commit boundary, waiting for replacement, re-querying the DOM, and asserting stable component state.
GUIDE 02
Build a Selenium Page Component Model for Large Suites
Master Selenium page component model with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 03
Custom Expected Conditions for Business-Level UI Readiness
Design custom Selenium Expected Conditions that wait for coherent business state, return typed evidence, and explain synchronization failures clearly.
GUIDE 04
Selenium Wait Commands: Implicit, Explicit, and Fluent Waits
Selenium wait commands explained with implicit, explicit, and fluent waits, practical examples, timing mistakes, flake fixes, and stable patterns.
GUIDE 05
Implicit vs Explicit Waits in Selenium
Compare implicit vs explicit waits in Selenium with clear examples, timing rules, common pitfalls, and reliable patterns that reduce flaky tests.
GUIDE 06
17 WebElement State and Interaction Interview Scenarios for Senior SDETs
Practice 17 senior WebElement scenarios covering DOM state, attributes, properties, interactability, stale references, and outcome verification.
GUIDE 07
20 Selenium Java Framework Design Interview Scenarios
Practice 20 Selenium Java framework scenarios covering JUnit lifecycle, driver factories, page boundaries, waits, telemetry, parallelism, and maintainability.