ScreenshotNeo

BlogHow-to

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.

By the ScreenshotNeo team1 October 20268 min read

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. commit and domcontentloaded can 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 commit creates 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

  1. Does the next operation need only navigation to start? Use commit.
  2. Does it need the initial DOM but not dependent resources? Use domcontentloaded.
  3. Does it need normal document resources? Keep the default load.
  4. Does it need data or a visible component rendered by JavaScript? Navigate, then assert that component.
  5. Does a click cause navigation? Click, then use waitForURL() or assert destination content.
  6. Are you considering networkidle or 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.