Blog / How to Test Infinite Scroll and Lazy Loading End-to-End
How-To

How to Test Infinite Scroll and Lazy Loading End-to-End

Infinite scroll and lazy loading tests flake because the list loads late and recycles its own rows. Wait on signals and assert invariants, not element counts.

An infinite scroll list cannot tell you how many items are on the screen, because the answer changes while you ask. The next batch is mid-flight on the network, the render is turning it into nodes, and a virtualized list is unmounting the rows you scrolled past.

A test that counts those items samples three moving parts at one instant and hopes they all settled. Here is how to test infinite scroll and lazy loading without racing the clock.

What you’ll learn

  • Why infinite scroll tests flake, and which of three moving parts is to blame
  • How to wait on the network and the DOM instead of a fixed sleep
  • Three invariants that survive a list that grows and recycles
  • How to test a virtualized list and a mobile feed

Why Infinite Scroll Tests Flake

Infinite scroll tests flake because they read the list while three independent processes are changing it. A virtualized list is almost like a conveyor belt with a window cut in the lid. The test sees the boxes under the window, never the warehouse, and the belt keeps moving while it looks.

  • Network: Decides when the next batch of records arrives.
  • Render: Decides when those records become DOM nodes.
  • Recycling: In a virtualized list, unmounts or reuses rows that scrolled out of view so the browser is not holding ten thousand nodes.

Together they produce four failure modes. Each is a variant of the non-deterministic test you already know, packed into one interaction.

  1. Fixed sleep: await page.waitForTimeout(2000) passes when the API is warm and fails when it is not. Playwright’s own docs warn that tests which wait for time are inherently flaky.
  2. Exact count: expect(cards).toHaveCount(60) assumes sixty rows are mounted. On a virtualized list that holds twenty, the assertion passes or fails by accident.
  3. Missing trigger: Many lists load the next page when a sentinel element near the end enters the viewport, which is the job of an IntersectionObserver. If your scroll never brings the sentinel into view, no fetch fires. If the CI viewport is tall enough to show it at page load, the first fetch fires before your test acts.
  4. No end: A scroll loop with no terminal condition stops one batch early or runs forever, depending on how much data the test account holds that day.

None of these is a selector problem, so rewriting locators will not close any of them.

Infinite Scroll vs Lazy Loading: Which Test Do You Need?

Lazy loading defers one asset, usually an image or an iframe, until it nears the viewport. Infinite scroll is pagination, where the page fetches the next batch of records as you approach the end of the current set. They fail in different places and need different assertions.

  • Lazy loading: The deferred asset renders once it enters view. For an image, check that naturalWidth turns non-zero, because MDN says it returns 0 while the image data is not available. An <img> tag in the DOM proves nothing.
  • Infinite scroll: Each batch appends, no record is dropped or duplicated across the boundary, and the list reaches a clean end.

The SEO side of lazy loading points at the same test. Google’s guidance on lazy-loaded content says Google Search does not interact with your page. Content that only loads after a scroll or a click is at risk of never being seen. A test runner that never triggers the load has the same blind spot, because the deferred content is not in the DOM when you assert.

How to Wait for the Next Batch Without a Sleep

Wait on the network response for the next page, then on the DOM growing to match it. Both are signals the app emits, and the test moves when the app does.

Register the response wait before you scroll, so a fast response cannot arrive unobserved. It is the same order Playwright’s network docs use for any request you wait on.

// Swap in your own endpoint. Assumes the first batch has already landed.
const nextPage = page.waitForResponse(
  r => r.url().includes('/api/feed') && r.status() === 200
);
await page.mouse.wheel(0, 4000);
const response = await nextPage;
expect(response.ok()).toBeTruthy();

page.mouse.wheel returns before the scroll finishes, per the Playwright docs. The response wait is what keeps the test honest. Do not swap in locator.scrollIntoViewIfNeeded() on the sentinel. It does nothing when the element is already completely visible, and a test that depends on it will wait for a fetch that never fires.

When you cannot key off a single request, poll the DOM. expect.poll retries until the count grows or its timeout expires, five seconds by default. A slow batch gets as long as it needs inside that window, and an API that runs slower needs a larger timeout.

const cards = page.locator('[data-testid="feed-card"]');
const before = await cards.count();
await page.mouse.wheel(0, 4000);                                  // trigger the next load
await expect.poll(() => cards.count()).toBeGreaterThan(before);

For fully deterministic CI, fulfill the feed endpoint from a fixture with page.route. That takes the real network out of the loop, and it pairs with the test isolation strategies that keep one test from leaking state into the next.

The guide to 2FA and OTP flows makes this move with one-time codes, reading them from a test inbox instead of off the screen. Own the source and you own the signal. Dropping the fixed sleep is also the first fix to try on any flaky Playwright test.

How to Assert an Infinite Scroll List Loaded Correctly

Assert three invariants in place of a count. Each stays true however the three moving parts line up, which is what makes it safe to assert.

  1. Growth: After a scroll that triggers a load, the number of unique record keys is strictly greater than before. You are asserting a direction, not a value.
  2. No duplicates: Collect the record IDs from each batch and assert none repeats. Off-by-one pagination bugs surface here.
  3. A real end: The list ends on something the app already produces, such as an end-of-results message, a disabled load-more button, or a response with no next cursor. Stop the loop on that state, never on a hardcoded scroll count.

Write the contract down before the test, meaning the stable key every record carries and the fields it must have. The invariants are assertions about it, which is why they survive a data set that keeps growing.

The loop below checks all three from the API payload, so DOM recycling cannot touch it. It assumes the endpoint returns { items: [{ id }], nextCursor }. Adapt the shape to yours.

const pages = [];
page.on('response', async (r) => {
  if (r.url().includes('/api/feed') && r.ok()) pages.push(await r.json());
});
await page.goto('/feed');
await expect.poll(() => pages.length).toBe(1);                       // first batch landed
while (pages.at(-1).nextCursor) {                                    // a real end: no cursor left
  const before = pages.length;
  await page.mouse.wheel(0, 4000);
  await expect.poll(() => pages.length).toBeGreaterThan(before);     // growth
}
const ids = pages.flatMap(p => p.items.map(i => i.id));
expect(new Set(ids).size).toBe(ids.length);                          // no duplicates across batches

This proves the data arrived. For proof that the rows rendered, pair it with a visible-target check from the next section. A good end-to-end testing suite asserts the promise the feature makes. For infinite scroll, that promise is correct, unique records until there are none left, whatever count the screen happens to show.

TLDR

A list that streams and recycles will not hold still for an exact count. Let a signal the app emits set the pace, then assert what stays true however the loading lines up.

How to Test a Virtualized List

Test a virtualized list by target. Bring the one row you care about into view and assert it is there, and leave the total out of it.

TanStack Virtual and react-window render only the rows in or near the viewport, and how many extra rows they keep on each side is an option you set (overscan and overscanCount). On native, Android’s RecyclerView reuses the view for items that scroll on screen. A list of ten thousand items can have a dozen rows mounted, so toHaveCount against it measures the window.

The catch is that a row which is not mounted cannot be located. scrollIntoViewIfNeeded() on it waits for an element that does not exist yet and times out. Walk the container one viewport at a time until the row mounts:

const list = page.getByRole('list');
const target = page.getByText('Order #420', { exact: true });
// The row is not in the DOM until the list scrolls near it, so walk toward it.
await expect(async () => {
  await list.evaluate(el => el.scrollBy(0, el.clientHeight));
  await expect(target).toBeInViewport({ timeout: 100 });
}).toPass({ intervals: [50], timeout: 60_000 });

Walking costs one step per screen, so it suits a row a few dozen screens down. For order #4182, use the jump the app already has, like a search box or a scroll-to-index call, then assert the row is visible.

If you need a total, read it from somewhere other than the recycled DOM. When the feed shows a running count in a header or returns one in the API response, assert on that. The collected-ID loop from the last section covers the full set without ever needing every row mounted at once. The DOM of a virtualized list is a viewport, not a database.

How to Test Infinite Scroll on a Mobile App

Native tests wait on what the app shows too. Four details change from web.

  1. Bounded steps: Send one swipe per step, such as XCUIElement’s swipeUp() or Appium’s mobile: scroll, and check the list after each one. A fling’s distance depends on its velocity, so the number of rows it crosses changes from run to run.
  2. Rows by content: Because RecyclerView reuses views and UICollectionView hands out reusable cells, the view at position 12 holds different data after the next scroll. Find the row by what it shows, for example with Espresso’s RecyclerViewActions.scrollTo(matcher), never by index.
  3. Cell waits: Wait for the target cell to exist before you assert on it. XCUITest’s waitForExistence(timeout:) returns a Bool you can assert on directly.
  4. Throttled network: Run at least one pass on a slow connection so the loader-appears-then-clears state gets exercised. The Android Emulator takes -netspeed and -netdelay flags, and on iOS you can use the Network Link Conditioner.

A scroll test that passes on office wifi and fails on a throttled connection is a familiar shape of mobile E2E flakiness. Step four reproduces that failure on demand.

Which Wait Strategy Fits Which Pattern

The six wait strategies below each listen to a different signal and break under different conditions. When a scroll test goes red, find your pattern in the table and check whether you are waiting on the right thing.

StrategyWhat it waits onFlakes whenBest for
Fixed sleepA guessed number of millisecondsThe load outlasts the sleepNothing. Delete it.
Poll DOM countRendered node count growingThe list is virtualized and the count stops tracking loaded recordsSmall, non-virtualized lists
Wait on network responseThe next-page fetch resolvingThe URL or trigger is ambiguousAny feed with a known API endpoint
Collect IDs from the payloadEvery response the feed returnsThe payload shape changesDuplicate and end-of-list checks that must survive recycling
Scroll target into viewA specific item becoming visibleThe item never loads at allVirtualized lists, “find item X” cases
Terminal-state assertionEnd marker or no next cursorThe app renders no clear end stateFull-list coverage

A solid feed test usually combines two rows, a network-response wait to advance and then a visible-target or terminal-state check. Search result lists shift under a test for different reasons, and they take that pairing too.

How Pie Tests an Infinite Scroll Feed

Scripted scroll tests come with plumbing to maintain, including the sentinel selector, the endpoint match, and the handling for recycled cells. Pie, our autonomous QA platform, takes a different route. You describe the feed behavior in plain English, and Pie runs it against your web app or mobile build, taking a screenshot at each step and deciding what to do next.

Written out, the feed test reads like the checklist you would hand a colleague. Each line is one step.

  • Scroll to the bottom of the feed.
  • Wait for more orders to load.
  • Search for order #4182 and open it.

Pie reads the screen after each step before it moves on, so step two waits on a state it can see instead of a duration you guessed. Make that state a stable one, like more orders on the list, because a spinner can clear before Pie looks again. You do not maintain a selector for the sentinel or the cards, so a redesigned feed leaves none of yours to repair. If the intended behavior changes, you edit the step.

Two things stay with you.

  • The scroll steps: Put each scroll or search in the test as its own step whenever the record you need to check is off screen.
  • The contract: Your team decides which record must appear and what the end of the list looks like, and Pie checks both against the screen. Totals and duplicates belong to the payload loop from earlier, since a screen shows only the rows in view.

Three Invariants Beat an Exact Count

A longer sleep only moves the race. The tests that hold up wait on the fetch and a visible target, assert growth, no duplicates, and a real end, and treat a virtualized DOM as a viewport.

Pie keeps the scroll plumbing out of your suite. You write the behavior as steps, with no sentinel selector of yours to maintain.

Start with the happy path, one test that scrolls to a real end and asserts the three invariants. Add the virtualized and mobile cases once that one stays green.

Tired of Debugging Flaky Scroll Tests?

Write the feed check in plain English. See a screenshot of every step.

Book a Demo

Frequently Asked Questions

No. Lazy loading defers one asset, usually an image or an iframe, until it nears the viewport. Infinite scroll is pagination that fetches the next batch of records as you reach the end of the current one.

A test written for one will miss the failures of the other.

The test is reading a list that keeps changing. A fixed wait races the network, and an exact element count fights a virtualized list that keeps only some rows mounted.

Wait on the fetch response or a visible target, and assert growth, no duplicates, and a real end.

Register page.waitForResponse for the next-page request, scroll with page.mouse.wheel, then await the response. If you cannot match one request, use expect.poll to retry until the element count grows.

Both wait on a real signal instead of a guessed number of milliseconds.

Test by target and ignore the total. A row that is not mounted cannot be located, so scroll the container in steps until it mounts, then assert it is in the viewport.

For totals, use a running count or the IDs collected from the API.

Assert a terminal state the app already produces, such as an end-of-results message, a disabled load-more control, or a response with no next cursor.

Stopping after a fixed number of scrolls is the version that breaks the day the data set changes.

The idea carries over from web, with native actions. Scroll in bounded steps, find rows by content instead of index because native lists reuse cells, and wait for the target cell to exist.

Run once under a throttled network so the loading state gets exercised.

Timing and viewport differ. A slower runner stretches the gap between scroll and data, so a fixed wait runs out, and a different viewport changes how many rows load at once and whether the sentinel is visible at page load.

Wait on signals and set the viewport explicitly.

Pie runs plain-English tests on web, iOS, and Android and takes a screenshot at each step. Write the scroll and the check as their own steps, such as scrolling to the bottom and then searching for a specific order.

There is no sentinel selector for you to maintain.

Adithya Aggarwal
Adithya Aggarwal
CTO & Co-founder at Pie

Eight years building search and delivery systems at Amazon. The kind of scale where flaky tests block billion-dollar releases. Now CTO at Pie, building AI agents that adapt when your UI changes. LinkedIn →