Testing Infinite Scroll and Lazy Loading in Playwright
Infinite scroll feeds and lazy-loaded images look effortless to users, but they are a minefield for test automation: the content you want to assert on simply does not exist in the DOM until you scroll, and naive page.waitForTimeout hacks make suites slow and flaky. In this guide you will learn how to test Playwright infinite scroll lazy loading reliably using auto-waiting locators, deterministic scroll loops, network interception, and image-load verification in TypeScript. Every example below uses only real Playwright APIs you can run today.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why Infinite Scroll Breaks Naive Tests
Traditional pagination renders all items (or a fixed page) into the DOM up front, so a test can immediately query them. Infinite scroll inverts this: a small batch loads first, and additional batches are fetched only when the user nears the bottom of the viewport. An IntersectionObserver or scroll listener triggers an XHR/fetch call, the response is appended, and the cycle repeats. Lazy loading applies the same idea to images and iframes via the native loading="lazy" attribute or a JavaScript observer that swaps data-src into src.
This creates three classic failure modes for tests:
- Content not yet in the DOM — asserting on item #200 fails because only 20 are rendered.
- Race conditions — you scroll, but the network request has not resolved, so the assertion runs against stale content.
- Detached nodes — virtualized lists (react-window, TanStack Virtual) recycle DOM nodes, so a locator captured earlier points to a removed element.
The fix is never page.waitForTimeout(3000). Instead, lean on Playwright’s web-first assertions, which auto-retry until a condition is met or the timeout expires, and on explicit waits tied to real signals like network responses or element counts.
The Core Scroll Loop in Playwright
The most robust pattern is a bounded loop: scroll, wait for the item count to grow, and stop when it stabilizes or hits a target. Counting locators with locator.count() and comparing across iterations gives you a deterministic exit condition rather than guessing at timeouts.
import { test, expect, Page } from '@playwright/test';
async function scrollUntilStable(page: Page, itemSelector: string, maxScrolls = 30) {
const items = page.locator(itemSelector);
let previousCount = await items.count();
for (let i = 0; i < maxScrolls; i++) {
// Scroll the last known item into view to trigger the next batch.
await items.last().scrollIntoViewIfNeeded();
// Wait for the count to grow past the previous value, or bail after 5s.
try {
await expect(async () => {
expect(await items.count()).toBeGreaterThan(previousCount);
}).toPass({ timeout: 5000 });
} catch {
break; // No new items loaded -> we have reached the end.
}
previousCount = await items.count();
}
return previousCount;
}
test('loads all feed items via infinite scroll', async ({ page }) => {
await page.goto('https://example.com/feed');
const total = await scrollUntilStable(page, '[data-testid="feed-item"]');
expect(total).toBeGreaterThan(20);
});
Two details matter here. First, scrollIntoViewIfNeeded() is preferred over manual window.scrollTo because Playwright scrolls the actual element into the viewport, which is exactly what triggers an IntersectionObserver. Second, the expect.poll-style toPass() block retries the count assertion, so you never sleep longer than necessary and never assert too early.
Scrolling a Container Instead of the Window
Many feeds scroll inside a fixed-height div rather than the page body. In that case scrollIntoViewIfNeeded still works, but if you need to drive the scroll manually use locator.evaluate to scroll the container element directly.
test('scrolls inside a fixed-height container', async ({ page }) => {
await page.goto('https://example.com/feed');
const scroller = page.locator('[data-testid="scroll-container"]');
const items = scroller.locator('.row');
let previous = 0;
for (let i = 0; i < 25; i++) {
await scroller.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
await expect.poll(() => items.count(), { timeout: 4000 })
.toBeGreaterThanOrEqual(previous);
const current = await items.count();
if (current === previous) break;
previous = current;
}
expect(previous).toBeGreaterThan(0);
});
Waiting on the Real Signal: Network Responses
Counting DOM nodes works, but the most reliable trigger-and-wait pattern pairs the scroll action with page.waitForResponse. You scroll, the app fires its pagination request, and you wait for that exact response before asserting. This removes the guesswork entirely and is far less brittle than polling counts when the API is slow.
test('waits for the pagination request after each scroll', async ({ page }) => {
await page.goto('https://example.com/feed');
const items = page.locator('[data-testid="feed-item"]');
for (let pageNum = 2; pageNum <= 5; pageNum++) {
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes(`/api/feed?page=${pageNum}`) && r.status() === 200
),
page.locator('[data-testid="feed-item"]').last().scrollIntoViewIfNeeded(),
]);
const body = await response.json();
expect(body.items.length).toBeGreaterThan(0);
}
await expect(items).toHaveCount(100);
});
The Promise.all wrapper is important: you must start waiting for the response before the scroll action fires, otherwise a fast API could resolve before the listener is attached and you would deadlock. The web-first assertion toHaveCount at the end auto-waits, so even if rendering lags behind the network slightly, the test stays green.
Mocking Endless Data with page.route
Hitting a real backend makes infinite-scroll tests slow and non-deterministic. With page.route you can intercept the pagination endpoint and serve synthetic pages, giving you full control over how many items exist, when the list ends, and how to simulate errors or empty states. This is the single biggest reliability win for these suites.
test('mocks paginated API for deterministic scrolling', async ({ page }) => {
const PAGE_SIZE = 20;
const TOTAL_PAGES = 4;
await page.route('**/api/feed**', async (route) => {
const url = new URL(route.request().url());
const pageNum = Number(url.searchParams.get('page') ?? '1');
const items = Array.from({ length: PAGE_SIZE }, (_, i) => ({
id: (pageNum - 1) * PAGE_SIZE + i + 1,
title: `Item ${(pageNum - 1) * PAGE_SIZE + i + 1}`,
}));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items, hasMore: pageNum < TOTAL_PAGES }),
});
});
await page.goto('https://example.com/feed');
const total = await scrollUntilStable(page, '[data-testid="feed-item"]');
expect(total).toBe(PAGE_SIZE * TOTAL_PAGES); // exactly 80 items
});
Because the mock controls hasMore, the app stops requesting after page 4 and your scroll loop exits cleanly with an exact, asserted count. You can flip a single variable to test the empty state (TOTAL_PAGES = 0) or simulate a failure by calling route.fulfill({ status: 500 }) on a specific page to verify your error UI. For recording real traffic once and replaying it offline, page.routeFromHAR is the natural next step.
Testing Lazy-Loaded Images
Lazy-loaded images need a different assertion than feed items. With native lazy loading the <img> has loading="lazy" and the browser defers the request until the image nears the viewport. With JavaScript lazy loading, the real URL lives in data-src and is swapped into src when the observer fires. You verify the swap happened and, ideally, that the image actually decoded.
test('lazy image loads only after scrolling into view', async ({ page }) => {
await page.goto('https://example.com/gallery');
const img = page.locator('img[data-testid="hero-50"]');
// Before scrolling: src is still the placeholder / empty, data-src holds the real URL.
await expect(img).toHaveJSProperty('complete', true); // placeholder loaded
const dataSrc = await img.getAttribute('data-src');
expect(dataSrc).toContain('/images/hero-50');
// Scroll it into view to trigger the IntersectionObserver swap.
await img.scrollIntoViewIfNeeded();
// The real URL is now applied and the bitmap has decoded.
await expect(img).toHaveAttribute('src', dataSrc!);
await expect.poll(async () => {
return img.evaluate((el: HTMLImageElement) => el.naturalWidth);
}, { timeout: 5000 }).toBeGreaterThan(0);
});
The naturalWidth > 0 check is the gold standard for confirming an image truly rendered: a broken image keeps naturalWidth at zero even if src is set. Combining the attribute swap assertion with the decode check catches both “observer never fired” and “image 404’d” bugs in one test.
Verifying Requests Are Actually Deferred
The whole point of lazy loading is that off-screen images are not fetched. You can prove this by listening to image requests and asserting that the off-screen image’s URL is absent until you scroll.
test('off-screen images are not requested until visible', async ({ page }) => {
const requested = new Set<string>();
page.on('request', (req) => {
if (req.resourceType() === 'image') requested.add(req.url());
});
await page.goto('https://example.com/gallery');
await expect(page.locator('img').first()).toBeVisible();
// A deep image should not have been fetched yet.
expect([...requested].some((u) => u.includes('hero-50'))).toBe(false);
await page.locator('img[data-testid="hero-50"]').scrollIntoViewIfNeeded();
await expect.poll(() => [...requested].some((u) => u.includes('hero-50')))
.toBe(true);
});
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Handling Virtualized Lists and Overlays
Virtualized lists only keep a handful of DOM nodes alive, so toHaveCount against the full dataset will never pass. Instead, assert that a specific item becomes visible after scrolling to it, or test that the rendered window slides correctly. Combine scrollIntoViewIfNeeded with a content assertion on a known item.
test('virtualized row becomes visible after scrolling', async ({ page }) => {
await page.goto('https://example.com/virtual-table');
const target = page.getByText('Item 480', { exact: true });
// Drive the virtual scroller until the target row is rendered and visible.
await expect(async () => {
await page.locator('[role="row"]').last().scrollIntoViewIfNeeded();
await expect(target).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 15000 });
await expect(target).toBeInViewport();
});
Two web-first assertions shine here: toBeVisible() waits for the recycled node to mount, and toBeInViewport() confirms it is actually scrolled on-screen rather than just attached. Cookie banners and “load more” promo overlays frequently block scroll-triggering elements; page.addLocatorHandler lets you register a one-time dismissal that fires automatically whenever the overlay appears mid-scroll, so your loop is never silently blocked.
Strategy Comparison: Which Wait Approach to Use
| Approach | Best for | Reliability | Real Playwright API |
|---|---|---|---|
Poll element count with toPass / expect.poll | Feeds where you only know items grow | High | locator.count(), expect.poll |
waitForResponse after scroll | Apps with a predictable pagination endpoint | Very high | page.waitForResponse |
Mock with page.route | Deterministic CI, edge cases, error states | Highest | page.route, route.fulfill |
scrollIntoViewIfNeeded + visibility | Virtualized / recycled lists | High | locator.scrollIntoViewIfNeeded, toBeInViewport |
waitForTimeout (anti-pattern) | Never — avoid | Low (flaky) | page.waitForTimeout |
Best Practices Checklist
- Always bound your scroll loop with a max iteration count so a broken “end” condition cannot hang CI forever.
- Prefer
scrollIntoViewIfNeededoverwindow.scrollTobecause it reliably triggersIntersectionObserver. - Pair scroll actions with
waitForResponseinsidePromise.allto avoid race conditions. - Use
page.routeto make data deterministic and to exercise empty, error, and last-page states. - Verify lazy images with
naturalWidth > 0, not just the presence of asrcattribute. - For virtualized lists, assert
toBeInViewport()on a target item rather thantoHaveCounton the full dataset. - Register
page.addLocatorHandlerfor cookie and promo overlays that interrupt scrolling.
Conclusion
Testing Playwright infinite scroll lazy loading comes down to replacing fixed sleeps with real signals: poll element counts with expect.poll, wait on the exact pagination response with waitForResponse, and make data deterministic with page.route. For lazy images, confirm the observer swap and assert naturalWidth > 0 to catch broken assets. Apply the bounded scroll loop and the strategy table above, and your scroll-heavy suites will be fast, stable, and trustworthy on CI instead of randomly red.
FAQ
How do I scroll to the bottom of an infinite scroll page in Playwright?
Use a bounded loop that calls locator.last().scrollIntoViewIfNeeded() and then waits for the item count to grow with expect.poll(() => items.count()) or for the pagination request via page.waitForResponse. Exit the loop when the count stops increasing or a max-iterations guard is reached. Avoid page.waitForTimeout, which causes flaky, slow tests.
How can I test that lazy-loaded images actually rendered?
Scroll the image into view with scrollIntoViewIfNeeded, assert the real URL is applied (for example toHaveAttribute('src', expectedUrl)), then verify the bitmap decoded by polling img.evaluate(el => el.naturalWidth) until it is greater than zero. A naturalWidth of zero means the image is broken even when the src attribute is present, so this check catches 404s that an attribute assertion would miss.
Should I mock the API or scroll against the real backend?
For most CI suites, mock with page.route and route.fulfill. Mocking makes the dataset deterministic, removes network flakiness, runs faster, and lets you assert exact item counts plus edge cases like empty results and last-page boundaries. Keep a small number of end-to-end tests against the real backend (or a recorded routeFromHAR session) to catch contract drift, but rely on mocked data for the bulk of your scroll and lazy-load assertions.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
