I have spent enough time debugging Playwright failures on feed-style pages to know that the test is usually not “broken” in the obvious sense. The app is doing exactly what it was designed to do: it renders a small slice of the DOM, fetches more data when the user scrolls, and swaps nodes in and out of view to keep the page fast. That is great for users and painful for automation.

When I need to stabilize Playwright tests for virtualized lists, I start by assuming three things are true until proven otherwise: the element I want may not exist yet, the element I found may disappear after a scroll, and the UI may reflow while I am asserting on it. Those assumptions shape how I write locators, waits, assertions, and scroll helpers.

This article is the practical version of that workflow. I will focus on the timing, locator, and scroll-anchoring mistakes that make virtualized UIs flaky, then show the patterns I use to make tests reliable in Playwright. The same ideas apply to Playwright infinite scroll testing, lazy loaded UI tests, and virtualized table automation.

Why virtualized UIs are so fragile in tests

A virtualized list does not behave like a normal static list. Instead of rendering every row or card, it keeps only the visible portion in the DOM, plus a small buffer. When you scroll, offscreen items are unmounted and new ones are mounted. Libraries like React Window, React Virtualized, AG Grid, and custom feed implementations all use some variation of this pattern.

That creates a few test-specific problems:

  • The locator can match zero elements because the item has not been rendered yet.
  • The locator can match the right element, but only for a moment before it gets recycled.
  • The page can scroll, then content shifts as images or async data load, which changes the viewport position.
  • Assertions can pass locally and fail in CI because the timing of mount, paint, and network completion is different.
  • Text may exist in the data layer, but not in the DOM, so “visible text” assertions do not work the way they do on static pages.

For virtualized UIs, “wait for element to exist” is often the wrong mental model. I usually need to wait for a stable state, not just a node.

Playwright is still a good fit here because it gives me strong locator semantics, auto-waiting, and scroll APIs. The official docs are a good baseline if you need a refresher on how locators and actions work: Playwright documentation.

The first mistake, asserting too early

The most common failure I see is a test that scrolls once and immediately expects the target card or row to be visible. That can fail for several reasons:

  1. The network request has not returned yet.
  2. The framework has not committed the new DOM nodes.
  3. The item exists in the data source but has not been virtualized into the viewport.
  4. The list has a buffer, so the item is technically loaded but not yet reachable via the current scroll position.

If I need to verify a specific item, I do not jump straight to the final assertion. I break the flow into smaller checks:

  • confirm that scrolling triggered the lazy load,
  • confirm that the content container changed in a meaningful way,
  • confirm that the item is actually attached and visible,
  • then assert the item content.

A simple pattern in Playwright looks like this:

import { expect, type Page } from '@playwright/test';

export async function scrollUntilTextVisible(page: Page, text: string) { const feed = page.locator(‘[data-testid=”feed”]’);

for (let i = 0; i < 10; i++) { if (await feed.getByText(text, { exact: true }).count()) { await expect(feed.getByText(text, { exact: true })).toBeVisible(); return; }

await feed.evaluate((el) => el.scrollBy(0, el.clientHeight));
await page.waitForTimeout(250);   }

throw new Error(Could not find text: ${text}); }

This is not the fanciest helper, but it is explicit about intent. I like this style when I need to debug a flaky virtualized table automation flow because it gives me a visible loop I can reason about.

Use stable anchors, not transient row positions

A virtualized list often reuses row containers, so selecting “the third row” is a trap. The third row after one scroll might not be the same data item after the next scroll. Even if the rows have consistent styling, the DOM order is not a reliable identity.

I prefer locators anchored to data attributes, unique labels, or stable content IDs. If the app exposes something like data-testid="user-card-42", that is ideal. If not, I use business-meaningful text combined with structural context.

Good examples:

typescript

const card = page.locator('[data-testid="user-card"]').filter({ hasText: 'Ava Chen' });
await expect(card).toBeVisible();

typescript

const row = page.getByRole('row').filter({ has: page.getByText('INV-20391') });
await expect(row).toBeVisible();

Bad examples:

  • locator('.row:nth-child(3)')
  • locator('div > div > div')
  • getByText('View') when there are ten identical buttons on screen

The problem with position-based locators is not just readability, it is identity. Virtualized rendering changes the relationship between DOM position and data identity. Stable tests need stable identity.

Wait for the right thing, not the obvious thing

Playwright does a lot of waiting automatically, but I still add explicit waits around the state transition that matters. For infinite scroll, the important signal is usually not “the spinner disappeared”, it is “the list gained new data” or “the sentinel triggered a load and the expected row appeared.”

Some waiting strategies I use frequently:

Wait for a network response that contains the new page of data

If the application exposes a predictable request pattern, I wait on it directly.

typescript

await Promise.all([
  page.waitForResponse((res) =>
    res.url().includes('/api/feed') && res.request().method() === 'GET' && res.ok()
  ),
  page.locator('[data-testid="feed"]').evaluate((el) => el.scrollBy(0, el.clientHeight)),
]);

This works well when the app uses a clear API boundary. It is less helpful when the front end batches multiple requests or when the virtualization library triggers prefetching asynchronously.

Wait for DOM growth or content change

Sometimes the DOM is the best signal available. I will capture the item count before scrolling, then wait for it to increase.

typescript

const feed = page.locator('[data-testid="feed-item"]');
const before = await feed.count();

await page.mouse.wheel(0, 1200);

await expect.poll(async () => feed.count()).toBeGreaterThan(before);

expect.poll() is useful when the app is eventually consistent and I need to wait for a state that is not tied to a single action.

Wait for a specific row or card to be attached and visible

If I know exactly what item should appear, I wait for that item rather than a generic loading indicator.

typescript

const item = page.getByRole('article', { name: /Ava Chen/ });
await expect(item).toBeAttached();
await expect(item).toBeVisible();

The toBeAttached() check is important in virtualization scenarios. It helps distinguish “the data is available somewhere” from “the node is in the DOM right now.”

Scroll the container, not always the page

Another mistake I see is scrolling window when the list actually lives inside a scrollable container. This happens constantly in dashboards, modals, and table panels. The page does not move, but the inner container does.

Before I automate a feed, I identify the real scrolling element. I often inspect the layout manually, then encode that in a helper.

typescript

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

If the list uses momentum scrolling or large buffers, I may prefer mouse wheel events against the list container.

typescript

await list.hover();
await page.mouse.wheel(0, 1600);

I avoid mixing page-level and element-level scrolling in the same test unless the product truly requires it. That mixed behavior is where a lot of flakes come from, because the browser may choose a different scroll target than I expected.

Handle scroll anchoring and content shifts

Scroll anchoring is one of the least discussed causes of flaky tests in lazy loaded UI tests. As items load, images resolve, fonts change, or cards expand, the browser may keep the viewport anchored to a particular point, which changes the apparent scroll position. A test that thinks it has scrolled to item 100 may still be sitting near item 97 because content above it grew or collapsed.

What I do about it:

  • prefer deterministic card heights in test environments when possible,
  • wait for images or async decoration to settle before asserting position,
  • avoid using hard-coded pixel distances as the only proof of progress,
  • assert on content identity, not just on scroll position.

If the app is built with known variable-height content, I usually expect some movement and write the helper accordingly. For example, I might check the last visible item instead of assuming an exact scroll offset.

typescript

const visibleCards = page.locator('[data-testid="feed-item"]');
const lastCard = visibleCards.last();
await expect(lastCard).toContainText(/Ava Chen|Ben Ortiz|Mina Patel/);

That kind of assertion is resilient because it validates the user-visible result instead of the browser’s internal positioning behavior.

Treat virtualization libraries as implementation details, but know their failure modes

I do not usually write tests that depend on the library internals, but I do want to know how the implementation behaves.

For example:

  • React Window typically reuses DOM nodes aggressively, so row identity can be unstable.
  • Infinite scroll feeds often use sentinels or intersection observers, which means you need real viewport movement, not just DOM events.
  • Table virtualization can render a fixed number of rows, then reposition them as you scroll, which makes nth-child selectors especially unreliable.

That means I design tests around observable user behavior, not internal row management.

A stable test for a virtualized table automation scenario might verify:

  • the table loads the first page,
  • a known row becomes visible after scroll,
  • the row’s cells contain expected data,
  • sorting or filtering changes the visible set appropriately.

A fragile test would try to count every row in the dataset or assume all rows are in the DOM at once.

A practical pattern for infinite scroll feeds

When I need to test an infinite feed, I usually write a helper that scrolls in steps, checks for new content, and exits once a target item appears. I keep the helper opinionated and small.

import { expect, type Locator } from '@playwright/test';

export async function scrollFeedToItem(feed: Locator, itemText: string) { for (let i = 0; i < 12; i++) { const item = feed.getByText(itemText, { exact: true }); if (await item.count()) { await expect(item).toBeVisible(); return; }

await feed.evaluate((el) => el.scrollBy(0, el.clientHeight * 0.8));
await feed.page().waitForTimeout(200);   }

throw new Error(Item not found: ${itemText}); }

This helper is deliberately simple. I have tried fancier versions that measure scrollHeight, track request counts, or use IntersectionObserver inside the page. Those can work, but they are harder to debug when a test fails in CI. My rule is that the helper should be understandable by the next engineer who opens the file at 9 p.m. after a broken pipeline.

In flaky UI automation, clarity is often worth more than cleverness.

Use text assertions carefully

Text is a strong signal, but virtualized UIs can make text assertions misleading in a few ways.

  • The text may be present in data, but not yet mounted.
  • The same text may appear in multiple recycled nodes.
  • A feed item might show truncated text, so exact matching fails while partial matching passes too broadly.

I choose the narrowest assertion that matches the product requirement. If the requirement says “the card title should contain the user name”, then I use toContainText. If it says “the row must show exactly this invoice number”, then I use toHaveText or an exact text locator.

typescript

const row = page.getByRole('row').filter({ hasText: 'INV-20391' });
await expect(row).toContainText('Paid');
await expect(row).toContainText('$128.40');

For feeds with repeated titles, I add another anchor, such as a timestamp or category label, so the test does not accidentally pass on the wrong duplicate item.

Be careful with indexes, counts, and “all items” expectations

I rarely assert the total number of items in a virtualized list unless the product truly renders every item in the DOM, which is uncommon. If the UI is virtualized, locator.count() tells me how many nodes are mounted, not how many records exist.

That distinction matters a lot when engineers write tests like:

typescript

await expect(page.locator('[data-testid="feed-item"]')).toHaveCount(100);

This will usually fail on a virtualized list, even when the UI is healthy. A better expectation is something like:

  • the visible slice contains at least one item,
  • the correct item appears after a controlled scroll,
  • the list advances when the sentinel is reached,
  • the same data item does not disappear unexpectedly during interaction.

If I need completeness, I usually verify it through the API or backend data contract, not through the rendered DOM.

Debugging flakiness with traces, screenshots, and targeted logs

When a Playwright test fails on a virtualized UI, I want more than a stack trace. I want to know what was visible, what scrolled, and what the browser thought happened. Playwright trace viewer, screenshots, and videos can help a lot here, especially when failures only happen in CI.

I also like adding temporary diagnostic logs around scroll loops:

console.log('scrollTop before:', await feed.evaluate((el) => el.scrollTop));
await feed.evaluate((el) => el.scrollBy(0, 800));
console.log('scrollTop after:', await feed.evaluate((el) => el.scrollTop));

This is not production test code, but it helps isolate whether the issue is:

  • the scroll action did not hit the right element,
  • the UI did not react to the scroll,
  • the item never loaded,
  • the locator matched the wrong thing.

The diagnosis changes the fix. If the scroll target is wrong, the helper should change. If the item never loads, the app or test data setup needs work. If the locator is ambiguous, the selector strategy needs refinement.

Make the test data predictable

A lot of virtualized UI flakes are not really “UI” problems, they are data problems. If the list is fed by live or semi-random data, the test has to chase moving targets. I strongly prefer seeded fixtures, deterministic API responses, or dedicated test accounts with controlled content.

For example, if I need to test lazy loaded cards, I want a data set where I know:

  • which item appears first,
  • which item appears after the second page load,
  • which item has a long title,
  • which item includes an image that loads slowly,
  • which row is filtered out after a search.

That lets me write tests for behavior instead of hoping a random feed happens to include the right item.

If I can stub the network in a test environment, I often do it. If I cannot, I make the assertions looser and focus on patterns that are stable across runs.

A CI checklist that cuts a lot of flake

In CI/CD, small timing differences become huge. A test that survives on a local machine can fail in a slower container, a headless browser, or a shared runner. If I am trying to stabilize Playwright tests for virtualized lists in CI, I check these items first:

  • run with enough viewport height to reduce unnecessary virtual jumps,
  • ensure fonts and CSS load consistently,
  • wait for API fixtures or seed state before starting the test,
  • avoid reusing a page state that depends on a previous scroll position,
  • isolate tests so one test’s list state does not affect another,
  • record trace artifacts for failures,
  • keep timeouts realistic, but not infinite.

A small GitHub Actions example might look like this:

name: ui-tests

on: [push, pull_request]

jobs: playwright: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npx playwright test

That YAML does not solve virtualization issues by itself, but it gives me a repeatable baseline. For background on the concept, the idea of continuous integration is worth reading in the general sense as well: continuous integration.

What I avoid when I review flaky virtualized tests

When I review a failing spec, I usually look for the same anti-patterns.

1. Blind fixed sleeps

A waitForTimeout(2000) often hides the bug and makes the suite slower. I only keep a timeout if I am waiting for a known animation boundary or using it as a temporary debugging aid.

2. Global selectors

Selectors like .card or text=Submit become fragile when the page contains repeated elements. I narrow them with role, test ID, or container context.

3. Assuming one scroll equals one page of data

Some lists prefetch aggressively, others load only after the sentinel is fully visible, and some need several smaller scrolls. I write helpers that can cope with multiple loads.

4. Verifying the wrong layer

If the business requirement is “the user can find item X”, I verify visible behavior. If the requirement is “the API returns item X”, I verify the API. I avoid forcing the DOM to prove something the backend already guarantees better.

5. Counting mounted nodes as a proxy for completeness

That is usually a mistake in a virtualized UI. The DOM is intentionally incomplete.

A decision tree I actually use

When a test around an infinite list starts failing, I ask these questions in order:

  1. Is the item data present in the environment?
  2. Is the list container the element being scrolled?
  3. Does the scroll action trigger any observable network or DOM change?
  4. Is the locator stable enough for recycled nodes?
  5. Am I waiting on the right signal, or just hoping the UI settles?
  6. Is this test validating the user experience, or trying to prove a backend invariant through the DOM?

That sequence usually tells me whether I need to change the test, the fixture, or the application.

A sane default recipe for Playwright

If I had to give one starting recipe for teams dealing with virtualized list automation, it would be this:

  • identify the actual scroll container,
  • use stable locators tied to data identity,
  • wait for the visible item you care about, not for arbitrary time,
  • use network or DOM-change signals to confirm loading,
  • avoid count-based assertions on virtualized content,
  • keep helpers small and readable,
  • make test data deterministic whenever possible.

Here is a compact example that combines several of those ideas:

import { expect, test } from '@playwright/test';
test('shows the target card after scrolling', async ({ page }) => {
  await page.goto('/feed');

const feed = page.locator(‘[data-testid=”feed”]’); const target = feed.getByText(‘Ava Chen’, { exact: true });

for (let i = 0; i < 8; i++) { if (await target.count()) break; await feed.evaluate((el) => el.scrollBy(0, el.clientHeight * 0.75)); await page.waitForTimeout(200); }

await expect(target).toBeVisible(); await expect(feed.getByText(‘Product Designer’)).toBeVisible(); });

This is not magical, but it is the kind of test I trust. It checks the experience a user would actually have, and it acknowledges that the list may need time and movement before the content appears.

Final thoughts

Virtualized lists are a good UI optimization and a predictable source of test flakiness. Once I stopped treating them like static pages and started treating them like dynamic, stateful viewports, my Playwright suites got much easier to reason about.

The core lesson is simple: stabilize Playwright tests for virtualized lists by testing what the user can observe, not what you wish the DOM looked like. That means better locators, better waits, better scroll handling, and better test data. If you get those right, infinite scroll feeds and lazy cards become manageable instead of mysterious.

For teams building these tests at scale, the biggest win is consistency. Write one good scrolling helper, use stable identity everywhere, and make the environment predictable. That alone removes a surprising amount of noise from flaky suites.