Virtualized tables fail tests in a very specific way: the UI looks correct to a person, but the DOM does not contain everything the test expects. Rows are recycled, offscreen items disappear, and content may render only after a scroll event or a network response. If your assertion depends on exact pixel position or the full row count in the DOM, the test will be fragile by design.

The reliable strategy is simpler: assert the data that should be present, the row identity that should be visible, and the scroll state that should survive navigation. That means testing content, not viewport luck.

The core problem, in plain terms

Three patterns get mixed together a lot, but they fail for different reasons:

  • Virtualized table or list, only the visible slice of items exists in the DOM at a time.
  • Infinite scroll, more items are fetched and appended as the user scrolls.
  • Scroll restoration, the app restores the user to a prior scroll position after back navigation or refresh.

A test that passes only when a row stays mounted in the DOM is not really testing user behavior. It is testing one rendering implementation.

That distinction matters because virtualization libraries are free to recycle DOM nodes. A row element can keep the same selector while its text changes from item 10 to item 110. That is why counting rows, reading fixed indices, or waiting for an element to “stay put” tends to break.

What to assert instead

Use assertions that survive recycled DOM nodes and lazy loading:

  1. Text or row identity, for example a customer name, invoice ID, or product code.
  2. Selection state or expansion state, if the UI exposes it.
  3. Loaded data count from the app contract, not the number of rendered nodes.
  4. Scroll restoration outcome, meaning the user returns to the same logical item or section.
  5. Network-backed state, when the UI itself is intentionally sparse.

If your app exposes row keys in accessible attributes, use them. If not, assert on visible text and relationships between cells in the row. Stable semantics beat CSS position every time.

A practical test shape for virtualized tables

For browser automation, the easiest stable pattern is:

  1. Open the table.
  2. Wait for the initial data request to complete.
  3. Scroll the container, not the page, if the table has its own overflow region.
  4. Wait for a target row to appear.
  5. Assert the content of that row.
  6. Optionally verify that an earlier row was recycled out of view, if that behavior matters.

Here is a Playwright example using a scroll container and content-based checks. Playwright’s docs cover locators, auto-waiting, and scrolling behavior, which are useful here because the framework waits on element state instead of fixed sleeps. See the Playwright documentation for the exact API details.

import { test, expect } from '@playwright/test';
test('finds a lazily loaded row by content', async ({ page }) => {
  await page.goto('/orders');

  const grid = page.locator('[data-testid="orders-grid"]');
  await expect(grid).toBeVisible();

  await grid.evaluate((el) => {
    el.scrollTop = el.scrollHeight;
  });

  const targetRow = page.getByRole('row', { name: /INV-10482/ });
  await expect(targetRow).toBeVisible();
  await expect(targetRow).toContainText('Pending');
});

The important detail is not the syntax. It is the intent. You are asking, “Does the user eventually see the right row?” not “Did this row sit at pixel Y=418 for 300 ms?”

Stabilizing waits around virtualization libraries

Virtualized UIs usually have two asynchronous layers, rendering and data loading. A test needs to wait for both.

1. Wait for the data request, not just the spinner

A spinner can disappear before the list has mounted all visible rows. When the data is fetched over HTTP, wait on the actual response that feeds the list. That is especially useful for infinite scroll, where a new batch should arrive only after the scroll threshold is reached.

await Promise.all([
  page.waitForResponse((res) => res.url().includes('/api/orders') && res.ok()),
  page.locator('[data-testid="orders-grid"]').evaluate((el) => {
    el.scrollTop = el.scrollHeight;
  }),
]);

2. Wait for the row to be attached, then visible

With virtualization, “present in the DOM” and “visible to the user” are different states. A row may be attached but still offscreen inside a scroll container. Prefer a visible assertion on the row you actually care about.

3. Avoid fixed delays

waitForTimeout(1000) usually hides a timing bug rather than fixing it. It also makes the failure window wider when the virtualization library or backend latency changes.

4. Use the virtualization library’s own signals when available

Some libraries expose explicit loading flags, item counts, or callbacks when rendering completes. If the application exposes those states to the DOM, assert them. If it does not, prefer network and content assertions over sleep-based timing.

Testing infinite scroll without overfitting to the scroll distance

The biggest trap in infinite scroll tests is assuming a specific scroll delta always triggers the next fetch. That is not portable across viewport sizes, browser zoom, or layout changes.

Instead, test the trigger and the outcome:

  • the user reaches near the end of the list,
  • a new network request is issued,
  • new items become available,
  • the newly loaded content is reachable and correct.

A useful pattern is to scroll until a sentinel item or loading marker appears.

const list = page.locator('[data-testid="activity-feed"]');
await list.evaluate((el) => {
  el.scrollTop = el.scrollHeight;
});
await expect(page.getByText('Loading more items')).toBeVisible();
await expect(page.getByText('Activity 250')).toBeVisible();

If the app loads pages in batches, also verify deduplication. Infinite scroll bugs often show up as repeated items, skipped pages, or a stale cursor after the second request.

What not to test

  • exact scrollTop values,
  • total DOM node counts,
  • row positions in pixels,
  • assumptions that the first 20 items are always mounted.

Those are implementation details of the virtualization strategy, not user requirements.

Scroll restoration after back navigation and refresh

Scroll restoration is a separate class of bug. The page may return to the right route but the wrong position, or the browser may restore the position while the app re-renders the list from scratch and jumps somewhere else.

A good restoration test checks three things:

  1. the route or tab is correct after navigation,
  2. the list is restored to the same logical section or item,
  3. the item is interactable after restoration.

Back navigation example

await page.goto('/reports');
await page.getByText('Report 240').scrollIntoViewIfNeeded();
await page.getByText('Report 240').click();
await page.goBack();
await expect(page.getByText('Report 240')).toBeVisible();

This is a content-based assertion, which is safer than inspecting window.scrollY alone. The browser may report the right scroll position while the list has not yet finished rehydrating. A user cares about the item, not the raw coordinate.

Refresh example

Refresh is useful because some apps persist scroll state in session storage or route state, while others rely on the browser history API. If your requirement is that refresh restores the same list position, verify both the restored item and the absence of a jump after hydration.

await page.reload();
await expect(page.getByText('Report 240')).toBeVisible();
await expect(page.getByText('Report 240')).toBeInViewport();

Use toBeInViewport() carefully. It is a good confirmation only after the row has already been identified by content. On its own, it is too weak for virtualization tests because any recycled row could occupy the same visible slot.

Debugging failures fast

When a virtualization test fails, the fastest path is usually not the test runner. It is the app state.

Check these first:

  • Was the expected network request made?
  • Did the response include the target record?
  • Is the row selector tied to text, row role, or a stable test ID?
  • Is the scroll container the one you are moving, or are you scrolling the window while the list lives inside an overflow panel?
  • Did a route change or hydration step reset the list state?

A simple debugging hook can help when the app uses a custom virtualizer.

await page.locator('[data-testid="orders-grid"]').evaluate((el) => {
  console.log('scrollTop', el.scrollTop, 'scrollHeight', el.scrollHeight);
});

If the list is inside an iframe, shadow root, or nested scroll container, the test must target the actual scroller. That is a location problem, not a synchronization problem.

A compact decision table

Scenario Best assertion Common failure mode Safer fix
Virtualized table Visible row text and role DOM count changes as rows recycle Assert row content, not total rendered rows
Infinite scroll New batch appears after scroll Scroll delta too small or too large Scroll to sentinel, wait for response, assert new item
Scroll restoration Same logical item after back or refresh Browser restores position before app hydration finishes Wait for item content, then confirm visibility
Lazy loaded rows Target row becomes available after network load Fixed sleep races the backend Wait for response and visible row

When a viewport assertion is still useful

Viewport assertions are not useless, they are just secondary. Use them after a stronger content assertion when you need to confirm the item is actually visible to the user.

Good examples:

  • a restored row should be visible after back navigation,
  • a focused cell should remain on screen after scrolling,
  • a “load more” trigger should enter view before the fetch fires.

Poor examples:

  • checking that row 18 is exactly 280 pixels from the top,
  • checking that a recycled row stayed mounted,
  • checking a page’s full row count in a virtual list.

Final rule of thumb

If a test breaks because the list virtualized correctly, the test is wrong. If it breaks because the user cannot find the item, the test found a real bug.

That is the line to keep in mind when you test virtualized tables and infinite scroll in browser automation. Anchor every check to the user-visible record, use waits that match the data flow, and treat scroll restoration as a content problem first, a coordinate problem second.

FAQ

How do I test a virtualized table if offscreen rows are not in the DOM?

Assert the row after scrolling it into view, not before. The test should move the container until the target row is rendered, then verify its text or role.

Why are fixed sleeps so unreliable for infinite scroll?

Because the next batch depends on scroll timing, network latency, and render time. A sleep can be too short on a slow run and too long on a fast one. Waiting on the response and the row is more stable.

Should I assert scrollTop in scroll restoration tests?

Only as a supporting check. The stronger assertion is that the same logical item or section is visible after back navigation or refresh.

What is the main difference between infinite scroll and virtualization?

Infinite scroll loads more data as the user moves down. Virtualization recycles DOM nodes so only a subset of items is mounted at once. A screen can use both at the same time.

How do I know whether the bug is in the test or the app?

If the row exists in the API response but never appears after the scroll trigger, it is usually an app or integration bug. If the row appears and disappears because the test depended on a specific rendered position, it is a test design problem.