Virtualized tables fail in a different way than normal tables. The UI may show only 20 rows, even when the dataset contains 20,000. Row elements get reused as you scroll. Offscreen content may not exist in the DOM at all. That means the usual “count the rows” assertion is mostly noise.

If you want to test virtualized tables in browser automation, focus on what virtualization guarantees, not on the illusion of a full rendered table. The useful checks are scroll anchoring, visible row identity, recycled DOM behavior, loading thresholds, and whether offscreen data becomes visible when expected.

A virtualization test that only asserts DOM count can pass while the user sees duplicated rows, skipped rows, or broken scroll positions.

Virtualization, windowing, and row recycling are not the same thing

These terms are related, but not identical:

  • Windowing means the app renders only the rows inside a moving viewport window.
  • Virtualized list testing is the broader QA activity of validating that windowing behaves correctly under scroll and data changes.
  • Row recycling means DOM nodes are reused for different data items as the user scrolls.

Many libraries do all three together, especially in data grids. The testing problem is that the same tr or div[role=row] may represent different records a moment later. So a locator that appears stable in a normal table can become misleading here.

The browser-side mechanism also matters. Some implementations use scroll position math, others use IntersectionObserver, and many combine both with overscan buffers. Your tests should verify the visible behavior, not guess the implementation.

The project setup: a small grid with predictable virtualization

For a reproducible QA project, build or clone a page with these behaviors:

  • 1,000 to 10,000 rows of deterministic data
  • fixed row height, at least for the first pass
  • a scroll container with a known viewport height
  • overscan enabled, so some rows render slightly outside the visible area
  • a row key that is stable across re-renders
  • a loading indicator for async data fetches or incremental page loading

A simple data generator is enough. What matters is that you can predict which item should be visible at a given scroll offset.

If you are using React, libraries like react-window or TanStack Virtual are common starting points. Their docs explain the core assumption: only a slice of the list exists in the DOM at once. That assumption should shape your tests.

The test strategy: assert user-visible behavior, not full DOM presence

For windowed UIs, I would organize tests into four layers.

1) Initial render and first viewport

Check that the first visible rows match the expected data.

What to verify:

  • the container renders
  • the first row is correct
  • a few rows in the initial viewport match their expected values
  • the scrollbar indicates that more content exists

Avoid this mistake:

  • expecting all 1,000 rows to exist in the DOM

2) Scroll anchoring

Scroll to a known offset and verify the row content at that position. Then scroll back and confirm the original content returns.

This catches bugs where virtualization loses its place, inserts duplicate items, or jitters when the list recalculates layout.

3) Recycled DOM nodes

Check that the same DOM node can represent different rows after scrolling, but still displays correct text, attributes, and row metadata.

This is where naïve locators often break. If you store an element handle for the first row and then scroll, that handle may still point to the same node, but the node now represents a different record.

4) Loading thresholds and offscreen assertions

Verify that additional rows load when the scroll position approaches the threshold where the next window should appear.

This is especially important for infinite scroll and hybrid virtualization, where new data arrives as the user nears the bottom.

A Playwright example that checks visible content by scroll position

The safest pattern is to compute the expected item from the scroll offset and assert against visible text after the UI settles.

import { test, expect } from '@playwright/test';
test('shows the correct row after scrolling', async ({ page }) => {
  await page.goto('http://localhost:3000/virtual-table');

  const grid = page.locator('[data-testid="virtual-grid"]');
  await grid.evaluate((el, y) => {
    el.scrollTop = y;
  }, 1200);

  await expect(page.getByText('Order #041')).toBeVisible();
  await expect(page.getByText('Order #042')).toBeVisible();
});

This example assumes fixed row height, so the expected row number can be derived from the scroll offset. If the rows have variable height, you need a different oracle, usually one based on known record IDs, not raw pixel math.

Handling recycled rows without brittle element handles

Do not cache a visible row element and assume it still means the same record after a scroll. Use row identity attributes if your app provides them, or assert on the text and accessible name of the currently visible row.

const row = page.locator('[data-row-id="order-41"]');
await expect(row).toHaveText(/Order #041/);
await page.mouse.wheel(0, 1000);
await expect(row).not.toHaveText(/Order #041/);

That second assertion is useful only if you expect the row node to be recycled. If the implementation keeps stable offscreen nodes, adjust the assertion to your actual rendering model. The point is to verify identity behavior explicitly, not assume one model.

Testing scroll anchoring when rows are inserted or filtered

Scroll anchoring breaks easily when the data set changes while the user is mid-scroll. That is especially true for grids that allow filtering, live updates, or prepend operations.

A good test is:

  1. Scroll to the middle of the list.
  2. Apply a filter that removes some visible rows.
  3. Confirm the UI keeps a sensible anchor row in view.
  4. Clear the filter and confirm the list returns to the same relative area.

If your app prepends new rows, test that the user does not get unexpectedly thrown back to the top unless that is intentional.

A practical assertion is to compare a record ID before and after the change, not the pixel position alone.

Testing threshold behavior with IntersectionObserver

Many windowed UIs use sentinels near the top or bottom of the list, and trigger fetches through IntersectionObserver. The right test is not to mock the browser API away completely unless you are isolating a unit. In browser automation, you want to prove the UI reacts when the sentinel enters the viewport.

Useful checks:

  • the loading spinner appears when the sentinel becomes visible
  • new rows append after the load completes
  • the same scroll position does not create duplicate fetches
  • moving slightly up and down does not trigger repeated loads

A compact Playwright pattern looks like this:

test('loads more rows near the bottom', async ({ page }) => {
  await page.goto('http://localhost:3000/virtual-table');

  const list = page.locator('[data-testid="virtual-grid"]');
  await list.evaluate((el) => {
    el.scrollTop = el.scrollHeight;
  });

  await expect(page.getByTestId('loading')).toBeVisible();
  await expect(page.getByText('Order #1000')).toBeVisible();
});

If the app debounces scroll events or fetches in batches, allow for that timing in the test. The goal is to verify the trigger and the resulting state, not a specific implementation delay.

Offscreen assertions are useful, but they need the right target

A virtualized list often keeps offscreen rows out of the DOM, so you cannot directly assert that an invisible row element exists. Instead, assert one of these:

  • the backing data source contains the record
  • the list scroll position reveals the expected record
  • the accessibility tree exposes the visible row with the correct label
  • the network response includes the data that should appear after the next scroll

For browser automation, offscreen assertions work best when you combine UI checks with a controlled API fixture. For example, seed the test database with predictable rows, then verify the row appears when scrolled into view.

That gives you a stable test without binding it to a specific virtualization algorithm.

Common failure modes worth testing explicitly

Duplicate or skipped rows

These usually show up after fast scrolling, resizing, or sorting. Test a few jump points, not just a single smooth scroll.

Wrong row after sort or filter

Recycling can expose stale text if the DOM node updates partially. Assert both the primary label and one secondary field, such as date or status.

Broken height calculations

If rows are variable height, small math errors accumulate. Scroll near the middle and near the end, where these errors become easier to see.

Overscan masking bugs

A generous overscan buffer can hide flicker in normal use but still fail under faster scroll input. Test with wheel events, keyboard paging, and programmatic scroll changes.

Re-render loops

If the list rerenders too often, the scroll position may jitter. A good signal is whether the current anchor row stays stable across a filter toggle.

A compact decision table for test design

Situation Primary check Avoid
Fixed-height rows scroll offset maps to expected row ID counting all rows in DOM
Variable-height rows visible record IDs and anchor row stability raw pixel-to-row math
Infinite scroll threshold trigger and new data arrival one-time snapshot assertions
Recycled DOM nodes current text and row metadata cached element handles after scroll
Accessibility-sensitive grid accessible row names and focus behavior CSS-only assertions

How I would split coverage in a real QA project

If the team owns the grid, I would keep coverage small and layered:

  • one happy-path scroll test
  • one filter or sort anchor test
  • one threshold-loading test
  • one accessibility-focused visible row test
  • one exploratory test for fast scroll, resize, and keyboard paging

That set is usually more durable than dozens of row-count assertions. It also fails for reasons the engineering team can act on.

What not to automate at the UI layer

Skip UI automation for details that belong in unit or component tests:

  • pure row-height math
  • item measurement utilities
  • adapter code that maps raw records into display labels
  • library-specific virtualization internals

Those are valuable, but they are not the browser-automation problem. Browser tests should prove that the user sees the right rows at the right time, after the virtualization layer does its work.

A practical rule of thumb

If your assertion starts with “there should be 200 rows in the DOM,” stop and rewrite it. For virtualized tables, the better question is:

“Given this scroll position and this data set, is the right record visible, stable, and interactively usable?”

That framing gives you tests that survive row recycling, windowing, and async loading without turning into brittle snapshot theater.

FAQ

Why do row-count assertions fail on virtualized tables?

Because only a slice of the data is rendered at once. The DOM is intentionally incomplete.

How do I verify recycled rows in browser automation?

Assert the visible row’s text, label, and metadata after scrolling, instead of reusing the original element handle.

Can I test infinite scroll the same way as virtualization?

Partly. You still verify visible rows, but you also need to assert the threshold trigger and the newly loaded data.

What is the best locator strategy for virtualized grids?

Prefer stable row IDs, accessible names, or visible text tied to known records. Avoid locators that depend on absolute row position alone.

Should I mock IntersectionObserver?

Only for isolated unit tests. In browser automation, it is better to verify the scroll-triggered behavior in the real page when possible.

How many virtualized list tests are enough?

Usually a few targeted tests cover more risk than a large suite of row-count checks. Focus on anchoring, visibility, loading threshold, and interaction stability.