How to Wait for Lazy-Loaded Content in Playwright
Wait for the content your Playwright test needs with locators and assertions, not fragile sleeps or network-idle guesses.
Direct answer: trigger the action that starts loading, then wait for the exact result your test needs. In Playwright, prefer a locator with a web-first assertion such as toBeVisible() or toHaveText(); use locator.waitFor() when you only need DOM state. A page load event, networkidle, or a fixed sleep does not prove that application data has rendered.
This pattern is reliable because it waits on an observable condition and retries while the page changes. The condition must match your requirement: one card, a completion marker, a count, or specific text.
1. The core pattern
- Identify what starts loading: navigation, a button, opening a panel, or scrolling.
- Choose a stable locator using a role, label, text, or test id.
- Trigger the behavior.
- Assert the expected result with a retrying web assertion.
import { test, expect } from '@playwright/test';
test('waits for a lazy-loaded result', async ({ page }) => {
await page.goto('https://example.com/catalog');
await page.getByRole('button', { name: 'Load more' }).click();
await expect(
page.getByRole('listitem').filter({ hasText: 'Expected item' })
).toBeVisible();
});
Assertions retry until they pass or the configured assertion timeout expires. Replace the button name and expected text with values that actually exist in your application.
2. Waiting for an element or state
Wait for visibility
await page.locator('[data-testid="loaded-content"]').waitFor({ state: 'visible' });
locator.waitFor() supports attached, detached, visible, and hidden. Visible means the element has a non-empty bounding box and is not visibility:hidden.
Wait for expected text or attributes
await expect(page.getByTestId('results')).toContainText('Invoice 1042');
await expect(page.getByTestId('results')).toHaveCount(20);
await expect(page.getByRole('status')).toHaveText('Loaded');
Use assertions when the requirement is semantic content or a final state. They communicate what the test proves and continue retrying while rendering catches up.
Wait for attachment without requiring visibility
await page.locator('[data-testid="prefetched-panel"]').waitFor({ state: 'attached' });
Attached only proves that a matching node exists in the DOM. It does not prove that a user can see it or that its text is final.
3. Scroll-triggered lazy loading
Scroll the relevant target as a user would, then wait for the resulting item or completion signal. Locator actions scroll an element into view when needed, including nested scroll containers, but that scrolling alone does not prove an infinite-scroll handler ran.
await page.getByRole('listitem').last().scrollIntoViewIfNeeded();
await expect(page.getByRole('listitem').filter({ hasText: 'New item' })).toBeVisible();
For a page-level sentinel:
await page.locator('[data-testid="infinite-scroll-sentinel"]').scrollIntoViewIfNeeded();
await expect(page.getByTestId('loading-indicator')).toBeHidden();
await expect(page.getByTestId('result-card')).toHaveCount(40);
If the list can grow repeatedly, wait for a meaningful completion condition before reading it. locator.all() returns the matches currently present; it does not wait for a changing list to finish.
4. “Load more” buttons and repeated batches
const items = page.getByRole('listitem');
const loadMore = page.getByRole('button', { name: 'Load more' });
for (let batch = 0; batch < 5; batch++) {
const before = await items.count();
if (!(await loadMore.isVisible().catch(() => false))) break;
await loadMore.click();
await expect.poll(() => items.count()).toBeGreaterThan(before);
}
await expect(items).toHaveCount(50);
expect.poll() is useful when the observable result is a number or another value rather than an element assertion. Add an application-specific end condition when the final count is unknown, such as a disabled button or an “all results loaded” marker.
5. Navigation and lifecycle waits
Use navigation waits for document lifecycle milestones, not as a substitute for application readiness.
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load');
// Then wait for the app-specific result:
await expect(page.getByTestId('dashboard')).toBeVisible();
Playwright documents commit, domcontentloaded, load, and networkidle. It explicitly discourages networkidle as a test readiness condition; pages can keep analytics, sockets, polling, or ads active, while deferred content may still be rendering.
6. Why networkidle and fixed sleeps fail
| Wait | What it proves | Typical problem |
|---|---|---|
load |
Document load event fired | Later fetches and rendering may not be complete |
networkidle |
Playwright’s navigation network-idle milestone | Background requests delay it; it still does not identify your content |
waitForTimeout(1000) |
One second elapsed | Too short on slow runs and wasteful on fast runs |
locator.waitFor() |
Selected DOM state | Only the selected condition is guaranteed |
expect(locator).toBeVisible() |
Expected visible result | Requires a locator that represents the real requirement |
Playwright discourages timer-based waits in production tests. Replace a delay with a signal such as a visible result, expected text, changed count, hidden spinner, or enabled control.
7. Choosing stable locators
- Prefer
getByRolewith an accessible name for buttons, headings, list items, and status messages. - Use
getByLabelfor form controls andgetByTestIdfor an intentionally stable test contract. - Use CSS selectors when the application exposes a stable data attribute.
- Avoid long positional selectors and styling classes that change during redesigns.
await expect(page.getByRole('heading', { name: 'Results' })).toBeVisible();
await expect(page.getByLabel('Search results')).toContainText('Acme');
await expect(page.locator('[data-testid="results"] article')).toHaveCount(10);
8. Timeouts and diagnostics
Keep the default timeout for normal cases and raise it only for a known slow operation. Configure assertion and action timeouts in the project when appropriate:
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: { timeout: 10_000 },
use: { actionTimeout: 10_000, navigationTimeout: 30_000 }
});
For one unusually slow assertion:
await expect(page.getByTestId('report')).toBeVisible({ timeout: 30_000 });
When a wait times out, inspect a trace, screenshot, and the locator count. Confirm the trigger happened, the locator matches the intended node, and the application did not show an error state.
9. Complete runnable example
import { test, expect } from '@playwright/test';
test('loads more products after scrolling', async ({ page }) => {
await page.goto('https://example.com/products', { waitUntil: 'domcontentloaded' });
const cards = page.getByTestId('product-card');
const sentinel = page.getByTestId('load-more-sentinel');
const error = page.getByRole('alert');
await expect(cards.first()).toBeVisible();
const before = await cards.count();
await sentinel.scrollIntoViewIfNeeded();
await expect.poll(async () => cards.count()).toBeGreaterThan(before);
await expect(error).toBeHidden();
const loaded = await cards.all();
console.log(`Cards currently rendered: ${loaded.length}`);
});
Install with npm i -D @playwright/test, install the browser with npx playwright install, save the test under tests/lazy.spec.ts, and run npx playwright test. Replace the example URL and test ids with your application’s real contract.
10. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Test continues before cards appear | Waiting for load only |
Assert a card, expected text, or completion marker after the trigger. |
networkidle times out |
Polling, analytics, sockets, or ads keep requests active | Remove the network-idle dependency and wait for the application result. |
| Assertion times out but the UI looks correct | Locator is wrong, hidden, duplicated, or scoped to the wrong frame | Check count(), use a role/test id, and target the correct frame. |
| Scroll does nothing | Scrolled the page instead of a nested container, or no handler is attached | Scroll the list/sentinel locator and assert the resulting state. |
| Only the first batch is captured | locator.all() ran while the list was changing |
Wait for a known count or completion signal, then call all(). |
| Fixed delay is flaky | Network and rendering time vary | Replace it with a retrying assertion or expect.poll(). |
| One item appears but more are still loading | Wait condition is weaker than the requirement | Wait for the final count, disabled “Load more,” or explicit completion marker. |
11. Performance and reliability
- Wait for the smallest condition that proves the behavior. This shortens runs and reduces dependence on unrelated requests.
- Prefer one semantic assertion over repeated polling of many selectors.
- Use deterministic fixtures or a completion marker when possible; variable third-party content makes tests slower and less repeatable.
- Keep timeouts aligned with realistic server latency. A very large timeout hides regressions; a tiny one creates false failures.
- Capture traces on retry to diagnose timing without adding sleeps to every run.
12. Or skip the browser setup:
If your goal is a rendered screenshot rather than an end-to-end assertion, ScreenshotNeo can wait for lazy content while it captures. Its full-page mode loads lazy images, and you can wait for a selector, a delay, or network idle, then capture the result through one API request. The API also supports custom JavaScript when a page needs an explicit scroll or click trigger.
Read the parameter reference in the ScreenshotNeo docs. cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the verdict and billing status. An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account.
13. FAQ
Should I wait for load or DOMContentLoaded?
Use those states only when you need the document lifecycle milestone. For lazy content, follow them with an assertion on the rendered result.
Can I use a fixed timeout for a one-off script?
You can, but it remains timing-dependent. An observable locator or state usually finishes sooner and explains failures better.
Does scrolling guarantee infinite scroll finished?
No. Scrolling triggers the page behavior; an assertion must verify the new item, count, or completion marker.
When is locator.waitFor better than expect?
Use it for a direct DOM state such as attachment or visibility. Use a web-first assertion when you need semantic text, counts, or another retried expectation.
Why does my screenshot still miss deferred content?
Make the capture wait for the relevant selector or trigger the page with custom JavaScript. A document load event alone does not represent every application fetch.
Primary references: Page navigation and load states, Locator waits and auto-scrolling, and web-first assertions.


