Browser tests that pass on a static page and fail once the UI starts loading content on demand are some of the most frustrating failures in automation. The app looks fine to a human, the feature works in manual testing, and the test only breaks when a list is virtualized, a feed uses infinite scroll, or content appears after the viewport changes. Those failures are rarely random. They usually come from a mismatch between what the test assumes the DOM looks like and what the browser is actually rendering at that moment.

The key idea is simple: dynamic content changes the shape of the page over time. A test that reads the DOM too early, scrolls too aggressively, or relies on stable element counts can pass locally and fail in CI with no code changes. If you are seeing browser tests fail after lazy loading, the problem is often not the locator itself, but the timing model, viewport state, and the fact that only part of the UI exists in the DOM at any given moment.

Why dynamic rendering breaks otherwise stable tests

Lazy loading, virtualization, and infinite scroll all solve performance problems by not rendering everything at once.

  • Lazy loading delays fetching or rendering content until it is needed.
  • Virtualized lists keep the DOM small by rendering only visible rows or cards, often plus a buffer.
  • Infinite scroll appends more items as the user approaches the end of the page.

From a test perspective, each of these creates a moving target. A locator may exist only briefly, a row may be detached and recreated as the user scrolls, or the element you want may not be present until the browser has scrolled far enough to trigger loading.

A stable test does not just wait longer, it waits for the right state.

That distinction matters because fixed sleeps hide the symptom without addressing the cause. If your test does wait 3 seconds and then clicks, it might pass on a fast laptop and fail in CI, or fail only when the list is long enough that the next batch of items is not ready yet.

The three failure modes you should distinguish

Before changing code, identify which of these patterns you have.

1. The content is not loaded yet

The element is not in the DOM because the request has not completed, the scroll trigger has not fired, or the component is still computing what to render. Typical signs:

  • NoSuchElementException, TimeoutError, or locator not found
  • The network tab shows pending or late requests
  • The page works after adding a manual wait, but the wait is inconsistent

2. The element exists, but not in the viewport or not clickable

The element may exist, yet be hidden behind a sticky header, off-screen, covered by an overlay, or in a collapsed section. Typical signs:

  • ElementClickInterceptedException
  • The element is found, but the click does nothing
  • The page auto-scrolls unexpectedly and then the locator becomes stale

3. The element is rendered, but it is not stable

Virtualized lists often detach and reattach nodes as you scroll. A row can disappear as the browser reuses DOM nodes for new items. Typical signs:

  • StaleElementReferenceException
  • Text assertions fail because the content changed after the element was captured
  • The same locator points to a different item after scrolling

Understanding which category you are in determines whether you should wait for network activity, wait for visibility, scroll more carefully, or change the assertion strategy entirely.

Start with the UI model, not the test code

If a test fails on dynamic content, inspect how the feature is built.

Ask these questions:

  • Does the page use IntersectionObserver to trigger loading?
  • Is the list virtualized with a library such as React Window, React Virtualized, or a custom recycler?
  • Does the app render skeletons first, then replace them with real content?
  • Does scrolling depend on a container element, not the whole window?
  • Are items sorted or filtered after the user reaches a specific position?

Those implementation details determine what the browser test should wait for. A window scroll may do nothing if the list lives in an inner div with overflow: auto. A locator that targets a row by index may be meaningless if the virtualized list reuses rows.

If you can, add temporary observability in the app or test environment:

  • a data-testid on the scroll container
  • a loading indicator with a clear lifecycle
  • request logging in test mode
  • a stable attribute for item identity, such as data-item-id

These are not just conveniences, they are debugging aids that turn invisible timing into visible state.

Reproduce the failure with the browser console open

A common mistake is trying to fix the test before proving what changed in the browser.

Use manual reproduction in the same browser mode as the test, ideally headless or with a low-resolution viewport if that is what CI uses. Watch for:

  • when the content first appears
  • whether scrolling triggers a network request
  • whether the list container itself scrolls
  • whether the element you want is removed and recreated

In Chromium-based tools, the Elements panel and network panel are especially useful. If a request is delayed or canceled, your test is probably asserting too early. If the item exists briefly and then vanishes, the DOM is being recycled.

A quick way to validate assumptions is to add console logging in the app for test builds, or to watch network requests in the test runner. In Playwright, for example, you can inspect requests and responses while the test runs.

page.on('request', request => {
  if (request.url().includes('/api/feed')) {
    console.log('feed request:', request.method(), request.url());
  }
});

This is often enough to tell whether your issue is a missing scroll trigger or simply an uncompleted fetch.

Prefer state-based waits over time-based waits

The most reliable tests wait for an observable condition that represents success.

Good conditions include:

  • the request that populates the list has completed
  • the loading spinner disappears
  • the expected item text appears
  • the scroll container height changes after loading more content
  • the item count reaches a known threshold

Bad conditions include:

  • sleeping for an arbitrary number of milliseconds
  • waiting for a page to be “done” without defining what done means
  • asserting immediately after a scroll event

Playwright example: wait for the content that matters

typescript

await page.getByTestId('feed').evaluate((el) => {
  el.scrollTop = el.scrollHeight;
});

await page.getByText(‘Order history’).waitFor({ state: ‘visible’ });

await expect(page.getByRole('listitem')).toContainText('Order history');

This still has a weakness, it assumes the item becomes visible in the DOM. For virtualized lists, you may need to scroll the container in smaller increments and wait after each step.

Selenium example: wait for visibility, not just presence

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

wait = WebDriverWait(driver, 10) wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, “[data-testid=’feed-item’]”)))

Visibility is better than presence, but for dynamic lists, visibility can still be misleading if the item is on-screen for a moment and then recycled. When that happens, assert on a stable business event, not a transient DOM node.

Handle virtualization as a different class of problem

Virtualized lists deserve special treatment because they change the meaning of “count” and “presence.” You may see 10 rendered rows even when the dataset contains 10,000 items. That is intentional.

Common mistakes with virtualized lists:

  • counting DOM rows to verify total data
  • targeting row indexes as if every item were rendered
  • storing a WebElement and using it after scrolling
  • assuming off-screen items exist in the DOM

Better strategies for virtualized UIs

  1. Assert on the data source or API contract when possible

    If the UI is only showing a subset, verify that the backend returned the right records, then separately verify the UI renders representative items.

  2. Use stable item identifiers

    Locate rows by text or data-item-id, not by index. If a row says “Invoice #4821,” that is much safer than “the third row.”

  3. Scroll in increments and re-query

    Do not capture a row and reuse it after scrolling. Query the DOM again after each scroll step.

  4. Assert the user-visible outcome

    For example, after scrolling to the bottom, verify that the last expected item is visible, or that the “load more” indicator disappears.

With virtualization, the DOM is a window, not the whole dataset.

That one sentence explains many false failures. The automation should treat the list as a changing viewport, not a static table.

Handle infinite scroll as a sequence, not a single action

Infinite scroll failures often come from assuming that one scroll event is enough. In practice, the app may need several increments before loading begins, and then several more before the requested items appear.

A robust test should:

  • scroll a little at a time
  • wait for the load trigger or network response
  • stop when the target item appears or a known end state is reached
  • fail with a clear message if the item never loads

Playwright pattern for incremental scrolling

typescript

async function scrollUntilText(page, text: string) {
  for (let i = 0; i < 10; i++) {
    if (await page.getByText(text).count()) return;
    await page.mouse.wheel(0, 1000);
    await page.waitForTimeout(250);
  }
  throw new Error(`Could not find ${text}`);
}

This example still uses a short timeout, but only as a pacing mechanism between scroll attempts. The real control is whether the target text appears. In a production suite, you may want to replace the timeout with a wait for a network response or a loading indicator.

If the scroll container is not the window, target the container directly. Many infinite scroll failures are caused by scrolling the wrong element.

typescript

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

Watch for viewport-dependent bugs

Some failures only appear at certain screen sizes because responsive layouts change what gets rendered or when lazy loading triggers.

Examples:

  • a sidebar collapses on smaller screens and shifts the scroll container
  • a sticky header covers the target after auto-scroll
  • a card grid becomes a single column, changing which items are visible
  • mobile viewport settings reduce the number of rows in a virtualized window

That is why a test can pass in a local browser but fail in CI, where the default viewport is different. For debugging, make the viewport explicit in the test setup.

import { defineConfig } from '@playwright/test';

export default defineConfig({ use: { viewport: { width: 1440, height: 900 } } });

If the failure disappears with a larger viewport, inspect whether the scroll trigger depends on intersection, whether the target is hidden by a header, or whether a container with internal scrolling is being used.

Eliminate stale element assumptions

Dynamic content frequently invalidates stored element references. This is especially common in Selenium, where a WebElement that was valid a moment ago can become stale after the framework re-renders the DOM.

The rule is straightforward, do not hold on to elements longer than necessary.

Instead of this pattern:

row = driver.find_element(By.CSS_SELECTOR, "[data-testid='feed-item']")
# scroll, wait, then use row again

Prefer re-locating the element after each state change:

wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-testid='feed-item']")))
rows = driver.find_elements(By.CSS_SELECTOR, "[data-testid='feed-item']")
assert any('Order history' in row.text for row in rows)

This reduces stale element errors and makes the test match how a user perceives the page, as a sequence of visible states rather than a set of long-lived DOM references.

Use network and performance signals when the UI is ambiguous

Sometimes the page gives you no reliable visual signal. In that case, use the network layer to anchor your waits.

Useful signals include:

  • the API request that loads the next batch of items
  • a response status code that proves loading completed
  • a custom event emitted by the app when the list is hydrated
  • the disappearance of a loading skeleton

Playwright example: wait for the next page of data

typescript

await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/items') && resp.status() === 200),
  page.locator('[data-testid="results-panel"]').evaluate(el => {
    el.scrollTop = el.scrollHeight;
  })
]);

This pattern works because the scroll action and the response wait happen together. If you trigger the scroll first and wait later, you can miss the request entirely.

For pages with throttled requests or intersection-based loading, network waits are often more reliable than DOM waits, especially when loading placeholders look identical to real content.

Build assertions that survive DOM recycling

A good assertion strategy for dynamic content asks, what does the user need to know?

Useful assertions:

  • the expected record becomes visible
  • the record count increases after loading more items
  • the last item in the sequence appears after scrolling
  • the loading indicator disappears before interaction
  • the right detail panel opens after selecting a visible item

Less useful assertions:

  • the page has exactly 100 .row elements
  • the third rendered card has a specific internal index
  • an element reference is the same before and after scrolling

If the UI recycles DOM nodes, count-based assertions can fail even when the product works. For example, a virtualized list may always render 12 rows, so a test expecting 50 visible nodes will be wrong by design.

A practical debugging checklist

When a browser test fails only after lazy loading, virtualization, or infinite scroll, run through this order:

  1. Confirm the scroll container
    • Is it the window or an inner element?
  2. Identify the trigger
    • Scroll position, intersection observer, button click, or API response?
  3. Inspect the loading state
    • Spinner, skeleton, placeholder, or silent fetch?
  4. Check for DOM recycling
    • Are nodes detached and recreated?
  5. Verify viewport assumptions
    • Does the failure depend on screen size or sticky UI?
  6. Replace fixed waits with state waits
    • Wait for content, network, or visibility.
  7. Re-query elements after each scroll
    • Never assume the same DOM node still exists.
  8. Prefer business assertions over structural ones
    • Verify what the user cares about, not internal rendering details.

If the test depends on the exact moment a list is rendered, the test is fragile by definition.

When to change the application, not the test

Sometimes the automation is exposing a real product issue. If the UI has no stable loading indicator, no test IDs, or ambiguous scroll containers, the best fix may be in the application.

Good product-side improvements for testability include:

  • consistent data-testid attributes on scroll containers and key items
  • an explicit loading state with deterministic start and finish
  • accessible roles and labels for interactive controls
  • predictable item identity in virtualized rows
  • separation between visual skeletons and real content

These are not test-only concessions. They often improve accessibility, observability, and maintainability at the same time.

Closing thought

When browser tests fail after lazy loading, virtualization, or infinite scroll, the failure is usually a signal that the test is making static assumptions about a dynamic UI. The browser is not wrong, it is rendering exactly what the app asked it to render. The test needs to synchronize with the real state of the page, not with the test writer’s guess about how long rendering should take.

If you focus on timing, viewport state, and partially rendered DOMs, most of these failures become diagnosable. If you focus on user-visible state, stable identifiers, and the right wait conditions, many of them disappear entirely.

Further reading