How to Test Virtualized Infinite Lists Without Missing Recycled Rows, Loading Gaps, or Offscreen Click Bugs
By Luca Müller · October 1, 2026
A practical guide to test virtualized infinite lists, covering recycled DOM nodes, loading thresholds, scroll boundaries, and click bugs after re-renders.
Virtualized infinite lists are where otherwise solid UI tests go to die quietly. The list looks simple to a human, but the DOM is being reused, rows are mounting and unmounting as you scroll, and the data layer is often loading in chunks that do not line up with what the browser is currently rendering.
If you need to test virtualized infinite lists reliably, the trick is not to chase every row. It is to verify the three behaviors that virtualization can break:
- Row recycling, the same DOM node gets reused for different data.
- Loading boundaries, new data arrives before the user hits a dead end.
- Interactivity after re-render, the row you click is still the row the app thinks you clicked.
That distinction matters because a lot of flaky infinite scroll automation comes from using the wrong assertion. Counting elements in the DOM is often the least useful signal in a virtualized list.
What changes when a list is virtualized?
A normal list keeps each row in the DOM. A virtualized list only renders the visible window, plus a small buffer above and below it. As you scroll, the framework reuses nodes or mounts new ones to represent different items.
That means these assumptions break:
locator.count()equals total data rows, it usually does not.- A row that existed a moment ago still exists after scroll, it may have been recycled.
scrollToBottom()guarantees the last item is visible, it may only get you to the next loaded page.- A click on an offscreen row is safe, only if the framework has actually brought that row into view and re-bound the event target.
The core testing problem is not just visibility, it is identity. A stable selector can still point at a node whose data has already changed.
The failure modes worth covering
1) Recycled row bugs
A virtualized row can keep the same DOM element while its text, index, and data all change. Bugs appear when the test or app assumes a DOM node is equivalent to a data record.
Typical symptoms:
- The selected row changes after scrolling, even though the UI still looks correct.
- Details in a row keep the old content because the component did not fully refresh derived state.
- Click handlers fire for the wrong item after recycling.
2) Loading gaps
Infinite scroll automation often checks whether more rows appear after a scroll event. That is necessary, but not enough. You also want to catch blank space between pages, duplicate rows at a page boundary, and spinner states that never resolve.
Typical symptoms:
- The viewport shows empty space before the next page arrives.
- The same item appears twice at the boundary.
- The feed stalls because the app stopped requesting data after a threshold.
3) Offscreen click bugs
With virtualization, a row may be offscreen, recycled, or partially rendered when the test tries to interact with it. Some failures are test bugs, but others are real app issues, especially when the row becomes clickable before it is fully populated.
Typical symptoms:
- Clicks land on the wrong row after fast scrolling.
- Buttons inside a row are visible but not clickable because an overlay or placeholder is still active.
- The item is in the DOM, but its accessible name or text has not updated yet.
A practical test strategy that survives virtualization
The safest approach is to test the list in layers.
Layer 1: data contract checks
Before the browser test, make sure the API returns predictable page sizes, stable item IDs, and a clear continuation signal. If the backend gives you duplicate IDs, empty pages, or overlapping offsets, UI tests will only show symptoms.
At minimum, verify:
- page size is consistent or intentionally variable,
- item IDs are stable and unique,
- the next-page cursor or offset changes when expected,
- the API can return an empty final page without breaking the feed.
Layer 2: scroll and render behavior
Here, you are checking that the list keeps the viewport filled and loads the next segment before the user reaches a dead end.
What to assert:
- the list never stays blank after a scroll trigger,
- a loading indicator appears when expected and clears,
- the visible range changes as you scroll,
- rows above the viewport are recycled instead of growing the DOM forever.
Layer 3: interaction after recycling
After a row is scrolled into view, click it, edit it, or open its menu, then confirm the action applies to the expected item. Repeat after scrolling away and back again.
This catches issues where a component keeps stale closures, stale keys, or stale local state.
Use stable item identity, not DOM position
For virtualized rows testing, the item ID is more important than the row index. Index-based assertions become fragile as soon as sorting, filtering, or pagination changes the rendered slice.
A better pattern is to render a stable attribute for test selection, such as data-testid tied to a business ID.
```html
<div data-testid="feed-row-1842">Order #1842</div>
Then assert on the item that was loaded, not on the nth child in the viewport.
If your app cannot expose a business ID, use the accessible name or a stable label that is derived from backend data. Avoid selectors based on translated copy unless the test is explicitly about localization.
## A Playwright pattern for scroll-driven validation
The example below checks that a row becomes visible after scrolling and that clicking it opens the correct item. It is deliberately small, because the hard part is not the API call, it is waiting for the list to stabilize after virtualization updates.
```typescript
import { test, expect } from '@playwright/test';
test('loads and opens a recycled row correctly', async ({ page }) => {
await page.goto('/feed');
const row = page.getByTestId('feed-row-1842');
await row.scrollIntoViewIfNeeded();
await expect(row).toBeVisible();
await row.click();
await expect(page.getByRole('dialog')).toContainText('Order #1842');
});
For virtualized lists, scrollIntoViewIfNeeded() is useful, but it is not magic. If the list loads in batches, you may still need to wait for the network response or a loading indicator to disappear before asserting the row content.
What to wait for, and what not to wait for
Waiting on the wrong signal creates flaky tests.
Good wait signals
- the loading spinner disappears,
- the API response for the next page completes,
- the target row becomes visible,
- the row text changes to the expected item ID.
Weak wait signals
- a fixed timeout,
- the number of DOM nodes in the list,
- a generic
networkidlecheck on a busy app, - scroll position alone.
For infinite scroll automation, the best wait condition is usually tied to the app state, not to the browser clock.
await expect(page.getByTestId('feed-loading')).toBeHidden();
await expect(page.getByTestId('feed-row-1842')).toHaveText(/Order #1842/);
How to test scroll thresholds without overfitting
Most infinite feeds load the next page when the viewport approaches the bottom sentinel. The threshold may be implemented with IntersectionObserver, scroll events, or a custom loader.
You do not need to test the implementation detail. Test the behavior:
- the request fires before the user hits the end,
- the loader does not trigger repeatedly on tiny scroll adjustments,
- the next batch appears in time to keep the list usable.
A simple way to cover that is to scroll in increments and assert that a new page loads when the sentinel approaches the viewport.
typescript for (let i = 0; i < 8; i++) {
await page.mouse.wheel(0, 800);
}
await expect(page.getByTestId('feed-loading')).toBeHidden();
await expect(page.getByTestId('feed-row-1900')).toBeVisible();
If the feed loads too early, you waste bandwidth. If it loads too late, users see blank space. Your test should catch both.
Edge cases that are easy to miss
Fast scroll followed by immediate click
This is where row recycling bugs show up. The visible row can change between the scroll and the click, especially if the app reuses the same node for a different record.
Filter changes on a virtualized list
Changing a filter should reset both the rendered window and the page cursor. If the test still sees a row from the previous query, the list state was not reset cleanly.
Keyboard navigation
Virtualized feeds often work for mouse users but fail for keyboard users because focus moves to a row that has already been recycled offscreen. If accessibility matters, test arrow navigation, Home, End, and Enter on visible rows.
Empty, short, and final pages
The last page should not leave the user at an empty gap. The final item needs to remain interactable, and the list should stop requesting more data after the end-of-feed condition.
A compact checklist for CI coverage
Use this as a minimum regression set for virtualized rows testing:
- First page renders with the expected visible rows.
- Scroll triggers the next page before the viewport runs out of content.
- Recycled rows update text, labels, and actions correctly.
- Clicking a scrolled-in row opens or selects the correct item.
- Filtering or sorting resets the rendered window and cursor.
- Final page does not duplicate items or leave blank space.
- Keyboard navigation still works after virtualization rerenders.
When browser automation is enough, and when it is not
Browser tests are good at proving the user sees and can click the right thing. They are not the best place to prove every pagination rule or every data transformation rule.
A sensible split is:
- API tests for cursor, offset, deduplication, and empty-page logic,
- UI tests for recycling, loading behavior, and interaction,
- accessibility checks for focus order and keyboard reachability.
That division keeps the browser suite focused on user-visible failures instead of duplicating backend coverage.
A decision rule you can use immediately
If the bug involves what data should load, test the API.
If the bug involves what the user can see or click after scrolling, test the UI.
If the bug involves which row is currently alive in the viewport, test the interaction after virtualization has rerendered.
That is the cleanest way to avoid writing long, brittle scripts that only verify that scrolling happened.
FAQ
Why do virtualized list tests become flaky so quickly?
Because DOM nodes are reused and item identity changes after scroll. If your assertions depend on row position or node persistence, they will fail when the list rerenders.
Should I count rows in the DOM?
Usually no. A virtualized list intentionally keeps only a subset of rows in the DOM, so total node count is not a useful proxy for total data.
How do I know a row click hit the right item?
Assert on a stable item identifier after the click, such as a detail view, dialog, or URL that includes the same business ID as the row.
What is the best selector for virtualized rows?
A stable test ID tied to business data is the safest choice. If that is not available, use an accessible name that is stable across rendering and localization.
Do I need special tooling for infinite scroll automation?
Not necessarily. You need predictable selectors, a reliable wait strategy, and assertions that check row identity after scroll. Most browser automation frameworks can do that if the app exposes stable hooks.