In Selenium's PageFactory, how often does a proxied List<WebElement> field query the DOM?
answer
- Count what crosses the proxy boundary
- Every List method is intercepted
- The handler finds before it forwards
- for-each routes only iterator()
- An indexed loop pays twice per row
basics
~10 sOnce per List method call. The list proxy runs findElements before it forwards any method, so size() then get(0) is two queries, and the WebElements handed back are ordinary handles rather than proxies.
solid answer
~40 sA `List<WebElement>` field is a `java.lang.reflect.Proxy` over `List`, backed by `LocatingElementListHandler`. Its `invoke` calls `locator.findElements()` **unconditionally** and only then forwards the method to the freshly returned list, so `size()`, `get(i)`, `isEmpty()` and `iterator()` are each a separate round trip. On a laundry pickup scheduler with eight slot rows, `for (WebElement slot : slots)` costs one query because only `iterator()` crosses the proxy, while an indexed loop costs seventeen. The elements you pull out are **ordinary** `WebElement` handles, not proxies, so they go stale on their own. Adding `@CacheLookup` inverts this: `DefaultElementLocator` stores the whole list on the first call and never re-queries, freezing both the count and every handle in it.
go deeper
Know that a list field is looked up again rather than held, and that the elements you take out of it can go stale. Recognising that the field is not a plain ArrayList is the key point.
Explain LocatingElementListHandler: findElements runs before every forwarded List method, so the query count follows the calls you make. Be able to compare a for-each with an indexed loop and say why they differ.
Show the production consequence: an indexed loop turning eight rows into seventeen round trips, indices shifting under a redraw, and held handles going stale. Be ready to say when the extra queries are worth paying.
Own the guidance a team follows: whether list fields are allowed at all, whether @CacheLookup on a list is banned outright, and how that decision is enforced in review rather than rediscovered per suite.
## Two proxies, two different shapes of laziness `DefaultFieldDecorator` produces two kinds of proxy. A `WebElement` field gets a proxy backed by `LocatingElementHandler`; a list field gets a proxy over the `List` interface backed by `LocatingElementListHandler`. The list case is also pickier about which fields it will touch at all: `isDecoratableList` accepts a field only when it is a `List`, its generic type argument is exactly `WebElement`, and it carries `@FindBy`, `@FindBys` or `@FindAll`. An unannotated `List<WebElement>` field is left untouched and stays `null` — unlike an unannotated single `WebElement` field, which still gets a proxy through the `ByIdOrName` fallback on the field's own name. ## What one List call costs `LocatingElementListHandler.invoke` is unconditional: ```java List<WebElement> elements = locator.findElements(); return method.invoke(elements, objects); ``` Every method that crosses the proxy — `size()`, `get(i)`, `isEmpty()`, `iterator()`, `contains(...)` — triggers a fresh `locator.findElements()` before it is forwarded. Selenium's own unit test for this handler asserts exactly that: after one `get(1)` and one size check, the locator has been asked for the elements **twice**. Note also what the list handler does *not* have. The single-element handler special-cases `toString` so a missing element prints instead of throwing; the list handler has no such branch, and it needs none, because `findElements` returns an empty list rather than raising `NoSuchElementException`. ## for-each versus an indexed loop Only calls that go **through the field** cost a round trip. Once `iterator()` has handed you an iterator, `hasNext()` and `next()` run against the snapshot it captured, not against the proxy. | Loop over a proxied `List<WebElement>` of eight pickup slots | Calls crossing the proxy | `findElements` round trips | |---|---|---| | `for (WebElement slot : slots) { ... }` | `iterator()` | 1 | | `for (int i = 0; i < slots.size(); i++) slots.get(i)` | nine `size()` plus eight `get(i)` | 17 | | `if (!slots.isEmpty()) slots.get(0).click();` | `isEmpty()`, `get(0)` | 2 | The indexed loop is the trap. It looks like ordinary Java and costs seventeen browser round trips on an eight-row slot list, and because each `get(i)` re-queries, a row inserted mid-loop shifts every index that follows it. ## The elements you take out are not proxies The values inside the returned list are whatever `searchContext.findElements(by)` produced: **ordinary** `WebElement` handles. They are not decorated, they do not re-find themselves, and the laziness of the field does not extend to them. - A slot element pulled out with `get(2)` and held across an action that redraws the panel goes stale like any other handle. - Assigning `WebElement first = slots.get(0);` at the top of a method and using it after a click is the usual way this bites. - Reading everything you need from the list in a single pass — one `getText()` per row while iterating — avoids both the extra round trips and the held-handle problem. ## Why `@CacheLookup` makes a list worse, not faster `DefaultElementLocator.findElements()` mirrors the single-element path: with the annotation present it stores the whole `List<WebElement>` in a private `cachedElementList` on the first call and returns that same list forever. 1. The **count** freezes. If a customer adds a pickup slot, `size()` still reports the old number, so the test asserts against a list the page no longer has. 2. Every handle inside the frozen list can go stale together, so a redraw turns one annotation into a whole list of permanently broken references. 3. Nothing invalidates `cachedElementList`, so the only way back is a new page object built by another `PageFactory.initElements`. A list is by definition the thing most likely to change, which makes it the single worst place to assert that an element "never changes". ## Practical shape on the pickup scheduler - Prefer a for-each, or one `List<WebElement> snapshot = slots;` followed by iteration, over an indexed loop that pays `size()` and `get(i)` on every turn. - Do not keep a `WebElement` taken out of the field across any action that redraws the list; re-read the field instead. - Leave `@CacheLookup` off list fields entirely unless the list is genuinely static markup. - If the cost of repeated `findElements` calls matters, the fix is to call the field once and work with the returned list, not to cache it in the locator.
- Why does a for-each over the field cost only one findElements call?Only methods invoked on the proxy are intercepted. A for-each calls `iterator()` once, which triggers one `findElements`, and the iterator it returns belongs to that snapshot list. `hasNext()` and `next()` then run against the snapshot, never touching the proxy again.
- Does an unannotated List<WebElement> field get a proxy like a plain WebElement field does?No. `DefaultFieldDecorator.isDecoratableList` requires the field to be a `List`, its type argument to be exactly `WebElement`, and the field to carry `@FindBy`, `@FindBys` or `@FindAll`. Without one of those the field is skipped and stays null, whereas an unannotated `WebElement` field still gets a proxy via the `ByIdOrName` fallback.
- What breaks when @CacheLookup is put on a list of slot rows?The whole list is stored on the first call and returned unchanged afterwards. The count freezes, so an added or removed slot is invisible to `size()`, and after a redraw every handle in the frozen list is stale at once. Nothing clears `cachedElementList`, so only a new page object recovers.
saying these in an interview costs you the question
- Says the list is fetched once at initElements and reused
- Thinks elements taken from the list re-find themselves
- Claims size() is free because the proxy caches the count
- Assumes @CacheLookup on a list makes it safer rather than staler
- Believes for-each and an indexed loop cost the same lookups