UI Testing Guide

How to Test Infinite Scroll with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing infinite scroll with Playwright. IntersectionObserver triggers, virtualized lists, loading spinners, end-of-list detection, scroll position restoration after navigation, and the pitfalls that turn scroll tests into the flakiest specs in your suite.

72%

72% of mobile commerce sites now use infinite scroll or progressive loading instead of traditional pagination, according to the Baymard Institute's 2025 UX benchmark of 214 major e-commerce sites.

Baymard Institute

0Scroll scenarios covered
0sAvg load trigger delay
0%Fewer lines with Assrt
0+Items loaded per test

Infinite Scroll Data Loading Flow

UserBrowser / DOMIntersectionObserverApp LogicAPI ServerScrolls downSentinel enters viewportCallback fires (isIntersecting: true)GET /api/items?cursor=abc{ items: [...], nextCursor }Append new DOM nodesRe-observe sentinelNew items visible

1. Why Testing Infinite Scroll Is Harder Than It Looks

Infinite scroll appears simple from a user perspective: scroll down, new content appears. But under the surface, the implementation relies on the IntersectionObserver API, a browser primitive that fires callbacks when a sentinel element enters the viewport. Playwright does not natively trigger IntersectionObserver by scrolling a page in the same way a human would. When you call page.mouse.wheel() or element.scrollIntoView(), the browser may or may not report the sentinel as intersecting depending on the scroll distance, the viewport size, and whether the sentinel has been laid out yet.

The complexity escalates when the list is virtualized. Libraries like react-window, react-virtuoso, and TanStack Virtual only render the items currently visible in the viewport (plus a small overscan buffer). That means the DOM never contains all the items at once. A naive test that counts page.locator('.item').count() will return the number of visible items, not the total loaded items. Scrolling to item 50 may remove item 1 from the DOM entirely, so any assertion referencing item 1 will fail.

Five structural challenges make this flow hard to test reliably. First, IntersectionObserver does not fire deterministically under programmatic scrolling, so your test must use explicit waits for new content rather than assuming the observer will trigger immediately. Second, virtualization recycles DOM nodes, making item counts and element references unreliable across scroll positions. Third, loading spinners and skeleton screens are transient states that appear for unpredictable durations, and asserting their presence requires careful timing. Fourth, the end-of-list boundary is a state machine transition (loading to empty to done) that many implementations get wrong. Fifth, scroll position restoration after browser back navigation depends on the scroll restoration API and framework-level caching, both of which behave differently in headed and headless browsers.

Infinite Scroll Lifecycle

🌐

Initial Render

First page of items

Sentinel Placed

Below last item

🌐

User Scrolls

Viewport moves down

🔔

Observer Fires

isIntersecting: true

⚙️

Fetch Next Page

API call with cursor

Append Items

Update DOM / state

↪️

Re-observe

Move sentinel down

2. Setting Up a Reliable Test Environment

Before writing any scroll tests, you need a deterministic data source. Infinite scroll is inherently stateful: each page request depends on a cursor or offset from the previous response. If your test database is shared or mutated between runs, the item count will drift and your assertions will break. Seed your test environment with a known dataset of at least 100 items so you can scroll through multiple pages and verify the end-of-list state.

Project Setup
playwright.config.ts
tests/helpers/seed-data.ts

The fixed viewport size in your Playwright config is critical. IntersectionObserver thresholds depend on how much of the sentinel element is visible, which depends on the viewport height. A viewport of 720px with 20 items per page (each about 80px tall) means the sentinel will be roughly 900px below the fold after the first page loads. Consistent viewport dimensions eliminate an entire class of flaky failures caused by different CI runner screen sizes.

Test Environment Setup Flow

⚙️

Seed Database

150 deterministic items

🌐

Configure Viewport

1280x720 fixed

🔔

Mock API Routes

Intercept /api/items

Disable Animations

prefers-reduced-motion

Seeding Test Data

3. Scenario: First Page Renders Correctly

Before testing scroll behavior, verify that the initial page of items renders correctly without any scrolling. This establishes a baseline. If the first page fails to load, every subsequent scroll test will fail too, and you will waste time debugging scroll logic when the real problem is data fetching or rendering.

1

First Page Renders with Correct Item Count

Straightforward

Playwright Implementation

tests/infinite-scroll/first-page.spec.ts

First Page Load: Playwright vs Assrt

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

test('first page renders 20 items', async ({ page }) => {
  await page.goto('/feed');

  const itemList = page.locator('[data-testid="item-list"]');
  await expect(itemList).toBeVisible();

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

  await expect(items.first()).toContainText('item-1');
  await expect(items.nth(19)).toContainText('item-20');

  const sentinel = page.locator('[data-testid="scroll-sentinel"]');
  await expect(sentinel).toBeAttached();

  await expect(page.getByText(/no more items/i)).not.toBeVisible();
});
33% fewer lines

4. Scenario: Triggering the Next Page Load

This is the core infinite scroll test and the one most likely to be flaky. You need to scroll the page until the sentinel element enters the viewport, wait for the IntersectionObserver callback to fire, wait for the API request to complete, and then verify the new items were appended. The critical insight is that you should never scroll by a fixed pixel amount. Instead, scroll the sentinel element itself into view and then wait for the new content to appear.

The reason fixed-pixel scrolling fails is that item heights can vary (especially with images or dynamic content), viewport sizes differ between CI environments, and the exact position of the sentinel depends on how many items rendered above it. Scrolling the sentinel into view using scrollIntoView() is deterministic because it always puts the sentinel in the viewport regardless of how far down the page it sits.

2

Scroll to Load the Second Page

Moderate

Playwright Implementation

tests/infinite-scroll/trigger-load.spec.ts

What to Assert Beyond the UI

Counting visible items is not enough. You should also verify that the correct API calls were made with the correct cursor values, that no duplicate items appear in the list, and that the scroll position is roughly where you expect it to be after new items are appended.

tests/infinite-scroll/api-verification.spec.ts

Trigger Next Page: Playwright vs Assrt

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

test('scrolling to sentinel loads next page', async ({ page }) => {
  await page.goto('/feed');
  const items = page.locator('[data-testid="feed-item"]');
  await expect(items).toHaveCount(20);

  const page2Request = page.waitForResponse(
    (resp) => resp.url().includes('/api/items')
      && resp.url().includes('cursor=')
      && resp.status() === 200
  );

  const sentinel = page.locator('[data-testid="scroll-sentinel"]');
  await sentinel.scrollIntoViewIfNeeded();
  await page2Request;

  await expect(items).toHaveCount(40, { timeout: 10_000 });
  await expect(items.nth(20)).toContainText('item-21');

  const allIds = await items.evaluateAll(
    (els) => els.map((el) => el.getAttribute('data-item-id'))
  );
  const uniqueIds = new Set(allIds);
  expect(uniqueIds.size).toBe(allIds.length);
});
50% fewer lines

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Loading Spinner and Skeleton States

When a user scrolls to the bottom of the current page, there is a window of time between the IntersectionObserver firing and the API response arriving. During that window, well-built applications show a loading indicator: a spinner, skeleton cards, or a progress bar. Testing this transient state is tricky because it may last only 50 to 200 milliseconds on a fast connection, making it easy to miss entirely if your test runs too fast.

The solution is to use Playwright's route interception to artificially delay the API response. By adding a controlled delay, you guarantee that the loading state is visible long enough to assert against. After verifying the loading state, you fulfill the intercepted request to let the page continue loading normally.

3

Loading Spinner Appears During Fetch

Moderate

Playwright Implementation

tests/infinite-scroll/loading-state.spec.ts

6. Scenario: Virtualized Lists and DOM Recycling

Virtualized lists (react-window, react-virtuoso, TanStack Virtual) only render the items currently in or near the viewport. When you scroll down to item 80, items 1 through 60 may be completely removed from the DOM. This is a performance optimization that dramatically reduces memory usage on long lists, but it fundamentally changes how you write assertions.

You cannot use page.locator('.item').count() to verify the total number of loaded items in a virtualized list because the DOM only contains the visible subset. Instead, you need to query the application state directly (via page.evaluate()) or assert against a UI element that displays the total count, such as a "Showing 80 of 150 items" label that the application maintains in its own state.

4

Virtualized List Handles 100+ Items

Complex

Playwright Implementation

tests/infinite-scroll/virtualization.spec.ts

Virtualized List: Playwright vs Assrt

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

test('virtualized list renders subset of loaded items', async ({ page }) => {
  await page.goto('/feed');
  const sentinel = page.locator('[data-testid="scroll-sentinel"]');

  for (let i = 0; i < 4; i++) {
    await sentinel.scrollIntoViewIfNeeded();
    await page.waitForResponse(
      (resp) => resp.url().includes('/api/items') && resp.status() === 200
    );
    await page.waitForTimeout(300);
  }

  const renderedItems = page.locator('[data-testid="feed-item"]');
  const renderedCount = await renderedItems.count();
  expect(renderedCount).toBeLessThan(50);

  const totalLoaded = await page.evaluate(() => {
    const counter = document.querySelector('[data-testid="item-counter"]');
    return counter ? parseInt(counter.textContent ?? '0', 10) : 0;
  });
  expect(totalLoaded).toBe(100);

  await page.evaluate(() => window.scrollTo({ top: 0 }));
  await page.waitForTimeout(500);

  await expect(renderedItems.first()).toContainText('item-1');
  const scrollHeight = await page.evaluate(() =>
    document.querySelector('[data-testid="item-list"]')?.scrollHeight ?? 0
  );
  expect(scrollHeight).toBeGreaterThan(4000);
});
53% fewer lines

7. Scenario: End-of-List Detection

Every infinite scroll implementation must handle the boundary where there are no more items to load. The API response signals this by returning an empty array, a null cursor, or a hasMore: falseflag. The application should stop observing the sentinel, hide the loading spinner, and display an end-of-list message such as "You've reached the end" or "No more items to load."

Testing this boundary is important because a common bug is the application making one extra API call after receiving the end-of-list signal, which wastes bandwidth and may cause the server to return an error for an invalid cursor. Another common bug is the loading spinner remaining visible permanently after the last page, because the code path that hides it only triggers when new items are appended (and no items are appended on the final response).

5

End-of-List Message and No Extra API Calls

Moderate

Playwright Implementation

tests/infinite-scroll/end-of-list.spec.ts

8. Scenario: Scroll Position Restoration After Navigation

A user scrolls through 80 items in a feed, clicks on item 75 to view its detail page, then presses the browser back button. They expect to land back at item 75, not at the top of the page. This is scroll position restoration, and it is one of the hardest UX features to implement and test correctly.

The difficulty comes from multiple layers. The browser's native scroll restoration (history.scrollRestoration) only works if the DOM is tall enough when the page loads to scroll to the previous position. In an infinite scroll application, the DOM starts with only the first page of items. The application must either cache the loaded items and re-render them instantly on back navigation, or use a framework-level caching mechanism (like Next.js router cache or React Query's stale-while-revalidate) to restore the previous state before the browser attempts to scroll.

6

Back Navigation Restores Scroll Position

Complex

Playwright Implementation

tests/infinite-scroll/scroll-restoration.spec.ts

Scroll Restoration: Playwright vs Assrt

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

test('back navigation restores scroll position', async ({ page }) => {
  await page.goto('/feed');
  const items = page.locator('[data-testid="feed-item"]');
  const sentinel = page.locator('[data-testid="scroll-sentinel"]');

  for (let i = 0; i < 2; i++) {
    await sentinel.scrollIntoViewIfNeeded();
    await page.waitForResponse(
      (resp) => resp.url().includes('/api/items') && resp.status() === 200
    );
  }
  await expect(items).toHaveCount(60, { timeout: 10_000 });

  const item50 = items.nth(49);
  await item50.scrollIntoViewIfNeeded();
  const scrollYBefore = await page.evaluate(() => window.scrollY);
  await item50.click();
  await page.waitForURL(/\/feed\/item-50/);

  await page.goBack();
  await page.waitForURL(/\/feed$/);
  await expect(items.nth(49)).toBeVisible({ timeout: 10_000 });

  const scrollYAfter = await page.evaluate(() => window.scrollY);
  expect(Math.abs(scrollYAfter - scrollYBefore)).toBeLessThan(200);
});
47% fewer lines

9. Common Pitfalls That Break Infinite Scroll Tests

Infinite scroll tests are among the flakiest in any test suite. The following pitfalls are sourced from real GitHub issues, Playwright discussion threads, and Stack Overflow questions where developers reported intermittent failures in their scroll test suites.

Pitfalls to Avoid

  • Using page.mouse.wheel() with fixed pixel amounts. Pixel amounts vary by viewport size and item height. Use scrollIntoViewIfNeeded() on the sentinel element instead.
  • Asserting item count on virtualized lists by counting DOM elements. Virtualized lists remove offscreen items from the DOM. Query app state or a UI counter instead.
  • Not waiting for the API response before asserting new items. IntersectionObserver fires asynchronously, and the fetch is another async hop. Always use waitForResponse() before checking counts.
  • Testing with an empty or unpredictable dataset. Non-deterministic seed data causes item counts and cursor values to drift between runs. Seed your test database with a fixed dataset.
  • Forgetting to handle the race between scroll events and route navigation. If a user clicks an item while a scroll-triggered fetch is in flight, the navigation may cancel the fetch and leave the app in a broken state.
  • Skipping viewport configuration. IntersectionObserver thresholds depend on viewport dimensions. Different CI runners have different default viewports, causing the sentinel to trigger at different scroll positions.
  • Not testing the empty state (zero items). Infinite scroll with zero results should show an empty state message, not an infinite loading spinner.
  • Using waitForTimeout instead of explicit waits. Hardcoded delays make tests slow in development and flaky in CI. Use waitForResponse, waitForSelector, or toHaveCount with a timeout.

Debugging a Flaky Scroll Test

When a scroll test fails intermittently, the most common root cause is the IntersectionObserver not firing after a programmatic scroll. Playwright's scrollIntoViewIfNeeded() relies on the browser's native Element.scrollIntoView(), which should trigger IntersectionObserver. However, if the sentinel is inside a container with overflow: hidden rather than overflow: auto, the scroll may not propagate correctly. Verify that your scrollable container uses the correct overflow property.

Debugging IntersectionObserver in Playwright
tests/infinite-scroll/debug-observer.spec.ts

10. Writing These Scenarios in Plain English with Assrt

The Playwright tests above are thorough, but they are also brittle. When your team switches from a sentinel-based IntersectionObserver to a scroll-event listener, or migrates from react-window to react-virtuoso, every data-testid and selector in those tests may need updating. Assrt lets you describe the same scenarios in plain English, and it compiles them into the Playwright TypeScript automatically. When the implementation changes, Assrt detects the DOM differences and opens a pull request with updated selectors.

Here is the complete Assrt file for the core infinite scroll scenarios. Each scenario block maps to one of the Playwright tests above.

scenarios/infinite-scroll.assrt

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections. The compiled tests are committed to your repo as real, runnable specs you can read, debug, and modify. When the application's scroll implementation changes (swapping the sentinel element, renaming data-testid attributes, migrating to a different virtualization library), Assrt detects the failures, analyzes the new DOM structure, and opens a pull request with the updated selectors. Your scenario files stay untouched.

Start with the first-page render scenario. Once it passes in CI, add the scroll-to-load test, then the loading spinner verification, then end-of-list detection, then virtualization handling, then scroll restoration. In a single afternoon you can have complete infinite scroll coverage that most teams never achieve with hand-written Playwright tests alone.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk