June 16, 2026
How to Debug Playwright Locator Failures That Only Appear in Virtualized Lists and Infinite Scroll
Learn why Playwright locators fail in virtualized lists and infinite scroll, how recycled DOM nodes break selectors, and how to debug flaky browser tests reliably.
Playwright is usually very good at finding elements, but virtualized lists and infinite scroll can make otherwise solid locators behave as if the UI is haunted. A selector that works locally may fail in CI, pass once and fail on rerun, or click the wrong item after a scroll because the DOM node you found has already been recycled.
The root problem is not Playwright itself. It is the mismatch between how tests think about a page and how modern list UIs actually render content. In a virtualized list, only a small window of rows exists in the DOM at any moment. In infinite scroll, more content is fetched and inserted as the user moves down the page. Between two assertions, the list can detach nodes, reuse elements, or replace text while your locator still points at an element that no longer represents what you intended.
This guide walks through why Playwright locator failures in virtualized lists happen, how to reproduce them, and how to debug them without turning every test into a pile of brittle sleeps. The same techniques also apply to other browser automation stacks, but the examples use Playwright because its locator model makes the failure modes easier to see.
What makes virtualized lists hard to test
A normal list renders every item in the DOM. If a list shows 2,000 rows, all 2,000 row elements are there. A virtualized list, by contrast, renders only the visible items plus a small buffer. As you scroll, rows are removed from the DOM and new ones are inserted in their place. Many implementations also recycle row containers to reduce layout work.
That design is good for performance, but it complicates automation in a few ways:
- The element you found a moment ago may be detached after scrolling.
- Two different rows can share the same structure, class names, or even the same text at different times.
- The item you want may not exist in the DOM until you scroll it into view.
- Hidden rows might be represented by placeholders, sentinels, or offscreen buffers that look selectable but are not interactable.
- Scrolling may trigger asynchronous data loading, which creates timing gaps between when the row appears and when it becomes stable.
If your test assumes that “found in the DOM” means “safe to interact with,” virtualized UIs will eventually prove that assumption wrong.
Infinite scroll adds another layer. The page is not just showing a moving window, it is also requesting more data as the user approaches the bottom. That means there are often two separate races:
- The browser is recycling DOM nodes while you scroll.
- The application is fetching the next page of results while your test tries to locate an item that has not been rendered yet.
The most common failure patterns
When Playwright tests fail against dynamic lists, the symptoms are often predictable.
1. The locator matches the wrong row
This usually happens when the list contains repeated labels, timestamps, or generic text like “View” or “Edit.” A locator such as page.getByText('Edit') may point to multiple rows, and after scrolling the first matching row may no longer be the one you expected.
2. The element disappears between locate and action
You locate a button, scroll, then click it. Between those steps the row gets recycled, so Playwright reports an element detached from DOM or an action timeout.
3. The test passes locally but fails in CI
Virtualized rendering is sensitive to viewport size, font metrics, timing, and CPU speed. A row that is visible in your local browser might require a slightly different scroll position in CI. That can change which rows are mounted at the moment your assertion runs.
4. The list loads more data, but your test checks too early
With infinite scroll, the item you want may arrive after a network response and a render pass. If your test only scrolls once and immediately looks for the target, it can fail even though the app eventually displays the row.
5. Hidden elements confuse the selector
Virtualized implementations often keep offscreen content in the DOM but hidden, or they create sentinel nodes used for intersection observers. A broad selector can accidentally hit a hidden row instead of the active one. This is a classic hidden element selectors problem, especially when the row markup is reused by the library.
First principle, identify the real contract of the UI
Before changing the test, figure out what the UI guarantees.
Ask these questions:
- Is the item uniquely identifiable by text, test id, or a stable attribute?
- Does scrolling cause DOM recycling, or only lazy rendering?
- Does the list use fixed-height rows or variable-height rows?
- Is the application fetching data on scroll, or does it already have the data client-side?
- Are there loading indicators, sentinels, or placeholders that should be waited on?
The answer affects the test strategy. A fixed-height virtualized table with stable row IDs can often be tested with a targeted scroll helper. A feed that loads content from the network as you approach the bottom needs explicit synchronization around data loading.
Start by making the failure observable
A flaky locator is much easier to reason about when you know what the DOM looked like at the moment of failure.
Use Playwright trace and screenshots
Playwright trace viewer is one of the fastest ways to understand whether the target was present, hidden, or recycled. Enable tracing on retries or in the failing test suite, then inspect the action timeline.
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ context }) => { await context.tracing.start({ screenshots: true, snapshots: true }); });
test.afterEach(async ({ context }) => { await context.tracing.stop({ path: ‘trace.zip’ }); });
Trace data helps answer questions like:
- Did the test scroll far enough?
- Was the item actually mounted when the locator ran?
- Did the UI re-render between
scrollIntoViewIfNeeded()andclick()? - Was there an overlay or loading state in the way?
Log what the locator sees
If you suspect the list is recycling elements, inspect the count and visible text around the target.
typescript
const items = page.locator('[data-testid="row"]');
console.log('row count:', await items.count());
console.log(await items.nth(0).textContent());
This is not a full debugging strategy, but it quickly tells you whether the page contains one row, ten rows, or a moving window of rows whose identities change as you scroll.
Check whether the node is detached
When a click fails, the problem may not be the selector. The element may have existed, but it got recycled before the action.
typescript
const row = page.getByTestId('invoice-row-1042');
await row.scrollIntoViewIfNeeded();
await expect(row).toBeVisible();
await row.click();
If this fails intermittently, the row might be re-rendering during scroll, which means the test needs a stronger synchronization point.
Prefer stable attributes over visible text when possible
Text-based selectors are fine when the text is unique and stable. They become fragile when rows are duplicated, localized, truncated, or reused across tabs.
For virtualized lists, the best locator is usually one of these:
data-testidon the row or action button- A stable accessibility role with a unique accessible name
- A row key or record identifier baked into the DOM in a test-only attribute
For example:
typescript
await page.getByTestId('customer-row-48391').getByRole('button', { name: 'Open' }).click();
This is better than locating the 27th row by position, because recycling changes positions as soon as the user scrolls.
That said, test ids should point to stable business entities, not implementation details that can disappear when the UI library changes. A row id derived from the backend entity is usually safer than a CSS class from a virtualization package.
Avoid brittle nth-child selectors
Selectors like locator('.row').nth(12) are tempting because they work quickly when you know where the item should be. They also fail as soon as virtualization inserts a buffer row, collapses spacing, or loads a different viewport size.
The problem is not only that index-based selectors are brittle. They often hide a deeper issue, which is that the test is asserting screen position instead of user intent.
Instead of “click the 12th row,” prefer “click the row for order 1042.” If you truly need to validate ordering, assert the rendered order after explicitly filtering or expanding the correct range.
Debug infinite scroll as a data-loading problem, not only a scrolling problem
A lot of teams treat infinite scroll tests like a pure viewport exercise. In practice, they are part UI, part data synchronization.
A useful pattern is to separate the scroll action from the wait condition.
typescript
async function scrollUntilTextVisible(page, text: string) {
for (let i = 0; i < 10; i++) {
if (await page.getByText(text).count()) return;
await page.mouse.wheel(0, 1200);
await page.waitForLoadState('networkidle').catch(() => {});
}
throw new Error(`Did not find ${text}`);
}
This helper is intentionally simple. In real tests, you may want a stronger condition than networkidle, because many apps keep background requests open. A better wait is often tied to the app itself, such as:
- a loading spinner disappearing
- a “load more” request finishing
- the row count increasing
- the sentinel element moving below the fold
If the app exposes request events, wait for the specific request that populates the list rather than waiting for the entire page to become idle.
Know when scrolling is not enough
Some virtualized components render items only after focus changes, keyboard navigation, or container-specific scrolling. Scrolling the page body may do nothing if the actual scrollable region is a nested div.
That means tests should target the scroll container explicitly.
typescript
const list = page.locator('[data-testid="virtual-list"]');
await list.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
Using mouse.wheel() on the page is sometimes fine, but it can be misleading if the app intercepts wheel events or if the actual scroller is inside a modal. In those cases, inspect the DOM to find the real scroll container and interact with it directly.
Handle recycled DOM nodes carefully
Many virtualized list libraries reuse row components for performance. That means the same DOM element may represent different data after scrolling. If your test stores a locator or handle to a row and then scrolls, the underlying node may no longer correspond to the same item.
This is why it is safer to re-query by stable identity right before the action.
typescript
const target = page.getByTestId('row-2048');
await target.scrollIntoViewIfNeeded();
await expect(target).toHaveText(/Order 2048/);
await target.getByRole('button', { name: 'Details' }).click();
What you want to avoid is this pattern:
typescript
const rowHandle = await page.locator('.row').elementHandle();
await page.mouse.wheel(0, 1000);
await rowHandle?.click();
An element handle is a snapshot of a specific node, which is exactly what virtualization tends to invalidate.
Use assertions that reflect rendering behavior
Playwright auto-waits for many actions, but assertions are still your best debugging tool. Choose assertions that verify the row is mounted and stable before you interact with it.
Useful assertions include:
toBeVisible()for active rowstoHaveText()for specific contenttoHaveCount()for deterministic subsetstoBeInViewport()when the row needs to be scrolled into view
Example:
typescript
const row = page.getByTestId('order-row-2048');
await expect(row).toBeVisible();
await expect(row).toContainText('Order #2048');
await row.getByRole('button', { name: 'Open' }).click();
When debugging, add temporary assertions around each phase of the interaction. First verify the list container exists, then verify the target text appears, then verify the button is visible, then click.
Watch for hidden element selectors
Hidden element selectors are a common source of confusion in dynamic lists. A list may have duplicate text in several places, but only one is visible. For example:
- a placeholder row and a real row share the same label
- a sticky header duplicates the column title
- a recycled row remains in the DOM but is translated offscreen
- a row is present but
aria-hidden="true"
If you use a broad text selector, Playwright may match the wrong copy of the text. Constrain the locator with a container or role.
typescript
const table = page.getByTestId('orders-table');
await table.getByRole('row', { name: /Order #2048/ }).click();
If the problem is still unclear, inspect visibility state in the browser devtools or use Playwright’s locator filters to narrow the search.
Build a scroll helper that waits for the right condition
A good helper can reduce flaky code across an entire suite. The trick is to make it data-aware rather than purely scroll-based.
typescript
async function findRowByText(page, text: string) {
const list = page.getByTestId('virtual-list');
for (let i = 0; i < 12; i++) {
const row = page.getByText(text, { exact: false });
if (await row.count()) return row.first();
await list.evaluate((el) => el.scrollBy(0, el.clientHeight * 0.8));
await page.waitForTimeout(100);
}
throw new Error(`Row not found: ${text}`);
}
This is not ideal for every test suite, but it is useful when the component under test has no backend hook to wait on. If you can wait on a network response or a state change in the app, do that instead of a fixed timeout.
The main idea is simple, keep the scroll and the detection logic together, and make the helper return only when the row is actually visible enough to act on.
Reproduce the bug with the smallest possible data set
If the failure only appears with 1,000 items, do not keep testing against 1,000 items while you debug. Reduce the problem.
Try these variants:
- 20 items, then 200 items, then 2,000 items
- fixed-height rows versus variable-height rows
- instant data load versus delayed API response
- desktop viewport versus mobile viewport
- local browser versus CI browser
That helps you isolate whether the issue is caused by virtualization itself, by timing, or by the way the app calculates layout. For example, variable-height rows can break assumptions about how many scroll steps are needed to reach an item, which makes a test look flaky when the real problem is an inaccurate scroll helper.
Debug the data path, not just the DOM
When a target item never appears, the bug may be in the network layer or state management, not the locator.
Check:
- Was the API request sent?
- Did it return the expected item?
- Did the frontend normalize the response correctly?
- Did filtering or sorting remove the row you expected?
- Did the item appear and then disappear because a new query replaced the old one?
You can inspect network calls in Playwright or wait for the response tied to the load event.
typescript
await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/orders') && res.status() === 200),
page.mouse.wheel(0, 2000)
]);
This is often more reliable than waiting for an arbitrary delay after scroll.
Decide when a locator should be rewritten versus when the app should be fixed
Not every flaky test can be solved by changing the test. Sometimes the component design is the issue.
Consider changing the application if:
- the same text appears in multiple rows without unique identifiers
- rows cannot be addressed by accessible names or stable attributes
- the list reuses DOM nodes in a way that breaks keyboard and screen reader behavior
- loading state is invisible to automation and users alike
- items flicker in and out of the DOM during ordinary interactions
Consider changing the test if:
- the locator is too broad
- the test depends on row position instead of identity
- the wait strategy is based on timing rather than state
- the code stores stale element handles across scrolls
- the test does not distinguish between hidden and visible copies of the same text
In practice, both sides often need work. A test that depends on brittle markup is a smell, but so is a UI that cannot expose stable semantics for automation and accessibility.
A practical debugging checklist
When a locator fails in a virtualized list or infinite scroll component, walk through this sequence:
- Confirm the target item exists in the data set.
- Inspect whether the item is actually rendered in the DOM.
- Verify you are scrolling the correct container.
- Check whether the row is visible or hidden.
- Replace broad text selectors with stable IDs or roles.
- Re-query the locator after each scroll step.
- Wait on the specific load or render signal, not a fixed timeout.
- Use traces and screenshots to see exactly when the node was detached.
- Compare local and CI viewport sizes, browser versions, and font rendering.
- If the row is recycled, stop relying on element handles.
A good mental model for these failures
Virtualized lists and infinite scroll are not just “slow pages.” They are pages where the DOM is intentionally incomplete and constantly changing. That means your test should treat the page as a stream of state transitions, not a static tree of elements.
Once you adopt that model, the fixes become more obvious:
- locate by identity, not by position
- wait for render state, not just network state
- re-query after scrolls
- verify visibility before interacting
- use the list’s own semantics, not raw CSS structure
The more your locators describe user intent, the less likely they are to break when content is recycled, lazy-rendered, or detached between scroll events.
Closing thought
If you are seeing intermittent Playwright locator failures in virtualized lists, the issue is usually not that Playwright is unreliable. It is that the test is asking a dynamic interface a static question. The fix is to make the question more specific, the wait condition more meaningful, and the locator more stable.
That combination usually turns a flaky infinite scroll test into a maintainable one, and it also makes the failure mode much easier to debug the next time the UI changes.