A Selenium @CacheLookup page-object field throws StaleElementReferenceException on every call after a re-render — why does it never recover?
answer
- Ask what the annotation actually stores
- The locator keeps a private field
- Nothing in Selenium ever clears it
- No reset method on the locator
- Only a new page object rebuilds it
basics
~10 s@CacheLookup makes DefaultElementLocator store the WebElement it found the first time and return that same instance forever. Nothing in Selenium invalidates that cache, so once a re-render detaches the element every later call throws.
solid answer
~40 s`@CacheLookup` is a marker that flips `DefaultElementLocator`'s `shouldCache` flag to true. The first call on the field runs `searchContext.findElement(by)` and stores the result in a private `cachedElement`; every call after that returns the stored instance without touching the DOM. Selenium 4 offers no invalidation hook — the locator has no `reset()`, nothing probes for staleness, and the proxy never inspects the exception coming back — so once the laundry pickup scheduler redraws its slot panel and the old node is detached, the field hands out the same dead handle forever. Only a fresh `PageFactory.initElements`, meaning a new page object, builds a locator with an empty cache. The honest fix is deleting the annotation: an uncached field re-runs `findElement` on every call, which is the default.
code
java · 27 linesimport java.util.List;
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 class PickupSchedulerPage {
@FindBy(id = "pickup-window")
@CacheLookup
private WebElement pickupWindow;
@FindBy(css = "[data-slot]")
private List<WebElement> slots;
public PickupSchedulerPage(WebDriver driver) {
PageFactory.initElements(driver, this);
}
public void chooseSlot(int index) {
slots.get(index).click();
}
public String windowLabel() {
return pickupWindow.getText();
}
}go deeper
Know that @CacheLookup means the element is looked up once and reused, and that reuse is unsafe on anything the page redraws. Recognising the annotation in a code review is enough here.
Explain the mechanics: a shouldCache flag, a private cachedElement written on the first call, and no code path that ever clears it. Be able to say what the uncached default does instead.
Diagnose it in a running suite: a field that fails identically on every retry, unaffected by waits or navigation, fixed only by a new page object. Be ready to argue for deleting the annotation rather than working around it.
Own the policy question: whether a saved round trip per call is worth a class of permanent failures, and how you stop a speed-motivated annotation from spreading across page objects a team shares.
## What the annotation actually switches on `@CacheLookup` is a **marker annotation** — `@Retention(RUNTIME)`, `@Target(FIELD)`, no members at all. Its own Javadoc describes what it asserts: that the element "never changes (that is, that the same instance in the DOM will always be used)". `Annotations.isLookupCached()` simply reports whether the field carries it, and the `DefaultElementLocator` constructor copies that answer into a `shouldCache` flag when `PageFactory.initElements` decorates the field. Nothing revisits the decision afterwards. ## The cache is one field, written once In Selenium 4 the whole caching mechanism is a handful of lines inside `DefaultElementLocator.findElement()`: ```java if (cachedElement != null && shouldCache()) { return cachedElement; } WebElement element = searchContext.findElement(by); if (shouldCache()) { cachedElement = element; } return element; ``` The first call on the field performs a real `findElement` and stores the returned `WebElement` in a private `cachedElement`. Every later call returns that stored instance without touching the browser. There is **no** branch anywhere that clears it: no `reset()`, no expiry, no staleness probe, and nothing in `LocatingElementHandler` inspects the exception that comes back from a call in order to invalidate anything. ## Why the failure becomes permanent A re-render detaches the node the stored handle points at. *Why* a detached node makes a handle stale is the driver's element-identity story rather than the page factory's; what the page factory adds is that the stale handle is now **permanent**. On a laundry pickup scheduler the sequence is mundane: 1. `pickupWindow.getText()` runs a real `findElement`; the element is stored in `cachedElement`. 2. The customer picks a different pickup date and the scheduler redraws its slot panel, detaching the old node. 3. The next `pickupWindow.click()` returns the stored handle and the command fails with `StaleElementReferenceException`. 4. Call four, call five and every retry return the same stored handle and fail identically. An uncached field would have recovered at step 3 on its own, because `searchContext.findElement(by)` would have run again and found the redrawn node. The annotation is the entire difference between a suite that shrugs off a redraw and one that dies at it, and because the failure is identical on every attempt, it is easy to misread as a locator problem rather than a caching one. ## Neither locator factory rescues a cached field | Field | Behaviour on call N greater than 1 | Recovers from a re-render? | |---|---|---| | `@FindBy` alone | runs `searchContext.findElement(by)` again | yes — a fresh element every call | | `@FindBy` plus `@CacheLookup` | returns the stored `cachedElement` | no — the same detached handle forever | | `@CacheLookup` under `AjaxElementLocatorFactory` | polls, but each poll delegates to the cache | no — polling cannot help | That third row is the one that surprises people. `AjaxElementLocator` overrides `findElement` to poll for a while instead of failing instantly, but each poll calls `DefaultElementLocator.findElement`, which short-circuits on `cachedElement`. Its `isElementUsable` hook returns `true` by default, so a detached element is happily accepted as usable and the poll succeeds immediately with the wrong answer. ## What actually clears the cache Only building a new locator does, and the only supported way to build one is another `PageFactory.initElements` — in practice, a new page object. - Assigning `null` to the field does nothing useful: `initElements` put a proxy there, and the cache lives in the locator behind the proxy, not in the field. - Navigating, refreshing or sleeping does nothing: no code path in Selenium touches `cachedElement`. - Catching the exception and repeating the same call does nothing: the repeat hits the same cache and throws again. - Swapping to `AjaxElementLocatorFactory` does nothing, for the reason in the table above. ## When the annotation is defensible - Use it only for what the Javadoc actually claims: a node created once and never replaced for the life of the page — the scheduler's static header, or the `<form>` element that wraps the booking fields. - Treat it as a liability on anything the application redraws: slot rows, availability lists, anything rendered after a background request completes, anything a component framework owns. - Weigh the saving honestly. It removes one `findElement` round trip per method call, which is small beside the cost of a suite that fails permanently after a redraw. - The uncached default — re-query on every method call — is precisely the behaviour that makes the proxy model tolerable, so removing the annotation is usually the whole fix and it is one line per field.
- Does switching to AjaxElementLocatorFactory make a cached field recover?No. `AjaxElementLocator` overrides `findElement` to poll rather than fail instantly, but each poll delegates to `DefaultElementLocator.findElement`, which returns `cachedElement` immediately when the annotation is present. Its `isElementUsable` hook returns true by default, so the detached element is accepted as usable and the poll succeeds with the wrong handle.
- Where does the cache actually live, and can a test clear it?It lives in the `DefaultElementLocator` instance created when `PageFactory.initElements` decorated the field, not in the field itself. There is no public accessor and no reset method, so a test cannot clear it. Building a new page object creates a new locator with an empty cache; that is the only supported route.
- When is @CacheLookup genuinely safe to use?Only when the annotation's own claim holds: the node is created once and never replaced for the life of the page. A static page header or the wrapping form element qualifies. Anything a component framework redraws, anything rendered after a background request, and any list of rows does not.
A cached field is a photocopy of one page from a loose-leaf binder: fine while nobody reorders the binder, and silently wrong from the moment somebody swaps that page out.
saying these in an interview costs you the question
- Claims @CacheLookup retries the lookup when the element goes stale
- Says the proxy automatically re-finds an element after a re-render
- Thinks AjaxElementLocatorFactory's timeout refreshes a cached field
- Believes assigning null to the field clears the locator's cache
- Assumes @CacheLookup caches only the locator, not the element