How to Wait for a Page to Finish Loading in Playwright
Learn when Playwright considers a page loaded, when to use each waitUntil state, and how to wait for the UI your test actually needs.
Direct answer: await page.goto(url) waits for the browser’s load event by default. That is enough when your next step only needs the document and its dependent resources. If your test depends on data rendered by the application, wait for that specific UI state with a locator or web-first assertion. Use domcontentloaded or commit only when the earlier lifecycle milestone matches what the next step needs.
The key distinction is browser lifecycle versus application readiness. A page can fire load while JavaScript is still fetching data, hydrating components, or rendering results. There is no universal “finished loading” event for every web app.
1. The default: wait for load
import { test, expect } from '@playwright/test';
test('opens the home page', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
});
page.goto() uses waitUntil: 'load' unless you provide another value. The browser fires load after the document’s dependent resources, including stylesheets, scripts, iframes, and images, have reached that milestone. See the Playwright page.goto API and the navigation guide.
For a direct navigation with no later application work, this is usually the clearest code:
await page.goto('https://example.com', { waitUntil: 'load' });
2. Choose the right waitUntil milestone
| Value | What it means | Use it when | Main risk |
|---|---|---|---|
commit |
The main response has been received and document loading has started. | Your code only needs navigation to begin. | The DOM and resources may not be ready. |
domcontentloaded |
The document’s DOMContentLoaded event fired. |
You need the initial DOM but not every dependent resource. | Images, styles, frames, and application data may still be loading. |
load |
The browser’s load event fired. |
You need the normal document resource milestone. | App data and late UI rendering can still continue. |
networkidle |
No network connections for at least 500 ms. | Only in unusual cases where network silence itself is the requirement. | Long polling, analytics, WebSockets, and background requests can make it unreliable. Playwright discourages it for tests. |
Use domcontentloaded when resources are irrelevant
await page.goto('https://example.com', {
waitUntil: 'domcontentloaded'
});
const heading = page.getByRole('heading', { name: 'Example Domain' });
await expect(heading).toBeVisible();
Use commit for the earliest navigation milestone
await page.goto('https://example.com', { waitUntil: 'commit' });
// The response has arrived and document loading has started.
// Do not assume that the DOM, images, or app data are ready yet.
Why networkidle is usually the wrong answer
// Avoid using this as a generic “the app is ready” signal:
await page.goto('https://example.com', { waitUntil: 'networkidle' });
networkidle means 500 ms without network connections; it does not mean that the required heading, table, or API result is visible. Modern applications may keep making background requests, while a page can become usable before network traffic stops. Prefer a condition that expresses the result your test needs.
3. Wait for the UI state your test depends on
When a page fetches data after navigation, assert the resulting content. Playwright web-first assertions retry until the condition is met or the assertion timeout expires.
import { test, expect } from '@playwright/test';
test('waits for search results', async ({ page }) => {
await page.goto('https://example.com/search?q=playwright');
await expect(
page.getByRole('heading', { name: 'Search results' })
).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(10);
});
Choose an assertion that represents readiness:
toBeVisible()for a heading, panel, or success message.toHaveText()for a status or server-rendered value.toHaveURL()for the destination after navigation.toHaveCount()when a result list must contain a known number of items.toBeEnabled()when the next action requires an enabled control.
4. Navigation caused by a click
Locator actions already wait for actionability: the locator must resolve correctly, be visible, stable, able to receive events, and be enabled. After the click, assert the URL or destination content.
import { test, expect } from '@playwright/test';
test('follows the next-page link', async ({ page }) => {
await page.goto('https://example.com/results');
await page.getByRole('link', { name: 'Next page' }).click();
await expect(page).toHaveURL(/next/);
await expect(
page.getByRole('heading', { name: 'Next page' })
).toBeVisible();
});
For a URL pattern or exact destination, use page.waitForURL() when you need to wait explicitly:
await Promise.all([
page.waitForURL('**/checkout'),
page.getByRole('link', { name: 'Checkout' }).click()
]);
await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible();
Avoid page.waitForNavigation(); Playwright marks it deprecated and inherently racy. Use waitForURL() or assert the destination state instead.
5. Using page.waitForLoadState()
page.waitForLoadState() waits for a lifecycle state on a navigation that has already committed. If the current document has already reached that state, it resolves immediately.
await page.goto('https://example.com', { waitUntil: 'commit' });
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('load');
await expect(page.getByRole('heading')).toBeVisible();
Most tests do not need a separate waitForLoadState() call because goto() and locator actions already wait for the relevant conditions. Use it when a lifecycle milestone is itself part of the operation.
6. Complete runnable project
Install Playwright
npm init playwright@latest
Or add the test runner to an existing project:
npm install -D @playwright/test
npx playwright install
Create a test
// tests/loading.spec.js
const { test, expect } = require('@playwright/test');
test('waits for the page and its application state', async ({ page }) => {
await page.goto('https://example.com', {
waitUntil: 'load',
timeout: 30_000
});
await expect(page).toHaveTitle(/Example Domain/);
await expect(
page.getByRole('heading', { name: 'Example Domain' })
).toBeVisible();
});
Run it
npx playwright test tests/loading.spec.js
npx playwright test --headed
npx playwright show-report
7. Waiting for a specific application pattern
Wait for a loading indicator to disappear
await page.goto('https://example.com/dashboard');
await expect(page.getByRole('status', { name: /loading/i })).toBeHidden();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Wait for a selector to appear
await page.goto('https://example.com/app');
await expect(page.locator('[data-testid="data-ready"]')).toBeVisible();
Wait for a response when the response is the dependency
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/products') && response.ok()
);
await page.getByRole('button', { name: 'Load products' }).click();
await responsePromise;
await expect(page.getByRole('list', { name: 'Products' })).toBeVisible();
Still assert the visible result when the test depends on rendering. A successful HTTP response alone does not prove that the UI consumed it.
Wait for a stable list before enumeration
await expect(page.getByRole('listitem')).toHaveCount(10);
const items = await page.getByRole('listitem').all();
locator.all() returns the elements present immediately; it does not wait for future matches. Wait for the expected state before enumerating.
8. Timeouts and reliability
Set timeouts according to the slowest legitimate environment, then keep the readiness condition specific.
import { defineConfig } from '@playwright/test';
export default defineConfig({
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
navigationTimeout: 30_000,
actionTimeout: 10_000
}
});
- Use a navigation timeout for slow servers or large documents.
- Use assertion timeouts for delayed application rendering.
- Keep locators specific so a broad match does not pass on the wrong element.
- Prefer deterministic test data and stable readiness markers such as
data-testid. - Do not hide real failures with a very large timeout or repeated fixed sleeps.
9. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
page.goto times out |
The server is slow, unreachable, redirecting repeatedly, or the timeout is too short. | Check the URL and server first; set a realistic navigation timeout and inspect the trace or error URL. |
| Test passes navigation but cannot find data | load fired before the app’s API response and rendering completed. |
Assert the result locator, status message, or other application-specific marker. |
networkidle never resolves |
Analytics, polling, WebSockets, or other background traffic stays active. | Remove networkidle and wait for the user-visible state. |
| Fixed sleep still flakes | The delay is shorter than some runs and longer than needed in others. | Replace waitForTimeout() with a locator or web-first assertion. |
waitForLoadState behaves unexpectedly |
It was called before navigation committed, or the requested state was already reached. | Call it after a committed navigation; remember it resolves immediately when the state already happened. |
| Click fails because an element is not actionable | The element is hidden, moving, covered, disabled, or the locator matches multiple elements. | Use a precise locator and let Playwright’s actionability wait; fix the page state instead of forcing the click. |
| Deprecated navigation warning | The test uses page.waitForNavigation(). |
Use page.waitForURL() and assert destination content. |
| List is empty after navigation | locator.all() enumerated before dynamic items appeared. |
Wait for a count or visible list container before calling all(). |
10. Performance and cost considerations
- Choose the earliest sufficient milestone.
commitanddomcontentloadedcan reduce unnecessary waiting when later resources are irrelevant. - Do not trade correctness for speed. If the next step needs an image, stylesheet, or rendered data, waiting only for
commitcreates flaky tests. - Condition-specific waits reduce idle time. Assertions finish as soon as the expected state exists instead of waiting for an arbitrary delay.
- Reuse browser contexts carefully. Shared state can make tests faster, but isolated contexts improve repeatability when cookies or local storage affect readiness.
- Capture traces for intermittent failures. A trace can show whether the delay came from navigation, an API response, actionability, or rendering.
11. A practical decision checklist
- Does the next operation need only navigation to start? Use
commit. - Does it need the initial DOM but not dependent resources? Use
domcontentloaded. - Does it need normal document resources? Keep the default
load. - Does it need data or a visible component rendered by JavaScript? Navigate, then assert that component.
- Does a click cause navigation? Click, then use
waitForURL()or assert destination content. - Are you considering
networkidleor a fixed sleep? Replace it with the condition the user actually needs whenever possible.
Or skip the browser setup
If your goal is a screenshot rather than an end-to-end browser interaction, ScreenshotNeo provides a single HTTP request. It handles the capture service and returns an image or PDF.
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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const body = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', body);
See the ScreenshotNeo API documentation for request options. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. ScreenshotNeo also includes an MCP server so Claude, Cursor, and other MCP clients can take screenshots, inspect pages, and capture PDFs. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.
Create your free ScreenshotNeo account and get 1,000 screenshots each month with no card.
FAQ
Does page.goto() wait for all JavaScript to finish?
No. It waits for the selected browser lifecycle state. Application code can continue fetching and rendering after load.
Is load faster than networkidle?
It depends on the page. networkidle waits for 500 ms without network connections and can be delayed indefinitely by background traffic. Use the milestone that matches the dependency.
Should I always use domcontentloaded?
No. Use it only when later resources are unnecessary. The default load is clearer when the document’s dependent resources matter.
What should I wait for after submitting a form?
Wait for the resulting URL, confirmation message, or updated content that proves the submission completed. Do not rely only on a generic delay.
Can I combine a lifecycle wait and a UI assertion?
Yes. Navigate with the lifecycle milestone you need, then assert the application state required by the next step.


