ScreenshotNeo

BlogHow-to

How to Wait Until a Page Is Fully Loaded in Playwright

Learn which Playwright load state to wait for, why load is not app readiness, and how to use assertions for reliable tests and screenshots.

By the ScreenshotNeo team1 October 20267 min read

Short answer: page.goto(url) waits for the load event by default. That means the document and its dependent resources have reached the browser’s load milestone. It does not prove that a modern application has finished fetching data, rendering a component, or loading lazy content. Wait for the specific state your next operation needs, usually with a locator assertion.

Playwright supports four navigation milestones: commit, domcontentloaded, load (the default), and networkidle. The right choice depends on whether you need a response, parsed HTML, dependent resources, or an application-specific condition. Playwright explicitly discourages networkidle as a general test strategy; use web assertions instead. See the Page API and navigation guide.

What “fully loaded” means

There is no universal fully loaded state. A browser lifecycle event and an application’s readiness are different signals.

Wait target What it means Use it when
commit A response has arrived and document loading has started. You only need navigation to begin.
domcontentloaded The target document fired DOMContentLoaded. DOM parsing is sufficient and you do not need every dependent resource.
load The document fired load, including dependent resources such as stylesheets, scripts, iframes and images. You need a sensible baseline before inspecting a conventional page.
networkidle No network connections for at least 500 ms. Only in narrow, deliberate cases; Playwright labels it discouraged for tests.
Locator assertion A visible, attached, enabled or otherwise expected UI state is true. The next action depends on application data or rendering.

A page can fire load while a client-side application is still requesting data, updating the DOM, polling, or loading images only after they enter the viewport. Define readiness in terms of the outcome your test needs.

Basic Playwright patterns

1. Use the default load milestone

import { test, expect } from '@playwright/test';

test('page has loaded its primary heading', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page.getByRole('heading', { name: 'Example Domain' })).toBeVisible();
});

The call waits for load by default. The assertion then waits for the condition that matters to the test and retries until it succeeds or the assertion timeout expires.

2. Select an earlier navigation milestone

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
// The DOM is parsed. Continue with an explicit condition:
await expect(page.locator('main')).toBeVisible();

Use commit or domcontentloaded when waiting for load adds no value to the operation. Neither state means that arbitrary application work is complete.

3. Wait for a known element or state

await page.goto('https://app.example.test/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('account-balance')).toHaveText('$42.00');

Web-first assertions are usually the clearest choice because they express the required result and retry automatically. For a lower-level wait, locator.waitFor() supports attached, detached, visible and hidden:

await page.locator('[data-testid="results"]').waitFor({ state: 'visible' });

Visibility requires a non-empty bounding box and no visibility:hidden. Prefer an assertion when you also want a useful failure message.

4. Wait for navigation caused by a click

const navigation = page.waitForNavigation({ waitUntil: 'domcontentloaded' });
await page.getByRole('link', { name: 'Details' }).click();
await navigation;
await expect(page.getByRole('heading', { name: 'Details' })).toBeVisible();

Create the navigation wait before the click so the event cannot be missed. In newer code, a URL or response assertion can be clearer when the click changes application state without a traditional full navigation:

await Promise.all([
  page.waitForURL('**/details'),
  page.getByRole('link', { name: 'Details' }).click(),
]);
await expect(page.getByRole('heading', { name: 'Details' })).toBeVisible();

5. Wait for dynamic data

await page.goto('https://app.example.test/orders');
await expect(page.getByRole('row', { name: /Order #1001/ })).toBeVisible();
const rows = await page.getByRole('row').all();

locator.all() returns matches currently present; it does not wait for a dynamic list to finish populating. First wait for a known result, expected count, or explicit “loaded” indicator. If the list can legitimately be empty, assert the empty state instead.

Choosing the right wait

  1. Identify the next operation. If you need only a response, use commit. If you need parsed markup, use domcontentloaded. If styles and images must be available, start with load.
  2. Find the application signal. Choose a heading, table row, spinner disappearance, status attribute, URL, or response that proves the required data is ready.
  3. Wait for that signal. Use a locator assertion, waitForURL, or a targeted response wait.
  4. Keep timeouts intentional. Set a test or assertion timeout based on your environment instead of adding arbitrary sleeps.
import { test, expect } from '@playwright/test';

test('reports become usable', async ({ page }) => {
  await page.goto('https://app.example.test/reports', {
    waitUntil: 'domcontentloaded',
  });

  await expect(page.getByTestId('reports-spinner')).toBeHidden();
  await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
  await expect(page.getByRole('row', { name: /January/ })).toBeVisible({
    timeout: 15_000,
  });
});

Why networkidle is usually the wrong answer

networkidle waits for a 500 ms period with no network connections. Analytics, polling, WebSockets, advertisements and lazy loading can prevent that quiet period or make it unrelated to usable UI. A page may also become idle before the exact component your test needs is rendered. Playwright’s API reference marks this option as discouraged for testing and recommends web assertions.

// Avoid as a blanket readiness rule:
await page.goto(url, { waitUntil: 'networkidle' });

// Prefer the state your test actually needs:
await page.goto(url, { waitUntil: 'domcontentloaded' });
await expect(page.getByTestId('checkout-form')).toBeVisible();

A controlled script that owns all requests may have a legitimate reason to observe network silence, but document that assumption and still assert the final UI state.

Common errors and fixes

Symptom Cause Fix
Test continues before data appears load completed before the app’s fetch or render. Assert the expected locator, text, count, URL or loading-state transition.
Navigation times out on networkidle Polling, analytics or persistent connections never become quiet. Use load or domcontentloaded, then wait for a specific UI condition.
Fixed waitForTimeout() is flaky The delay is unrelated to actual readiness and varies by machine. Replace it with a web-first assertion or targeted locator wait.
locator.all() returns too few items The list is still changing when matches are read. Wait for a known item, expected count, or completion indicator first.
Click happens before a control is usable The locator exists but is not actionable yet. Use a locator action; Playwright auto-waits for actionability. Add an assertion if a business state must be true.
Expected navigation wait hangs The click updates the SPA without a document navigation, or the wait was registered too late. Register the wait before the click and use waitForURL, a response wait, or a UI assertion for SPA transitions.
Element is attached but not visible It has no rendered box, is hidden, or is covered by the app’s loading state. Wait for toBeVisible() and, if needed, for the overlay or spinner to be hidden.

Reliability and performance guidance

  • Wait for the earliest milestone that satisfies the operation; unnecessary load waits slow suites.
  • Use stable, user-facing locators such as roles, labels and test IDs instead of brittle CSS tied to layout.
  • Keep navigation and assertion timeouts separate so failures identify whether loading or application readiness failed.
  • For pages with lazy content, scroll or trigger the behavior required by the test, then assert the resulting content.
  • For screenshots, wait for the exact visual state: fonts, a chart, a table, or a consent state. A lifecycle event alone cannot guarantee visual completeness.
  • Capture traces, screenshots and the current URL on timeout to diagnose slow or incorrect readiness conditions.

Complete runnable example

import { chromium, expect } from '@playwright/test';

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  try {
    await page.goto('https://example.com', { waitUntil: 'load' });
    await expect(page.getByRole('heading', { name: 'Example Domain' })).toBeVisible();
    console.log('Application-ready URL:', page.url());
  } finally {
    await browser.close();
  }
})();

Run it in a project with Playwright installed and an assertion library available. Replace the heading with the condition that proves readiness for your page.

Or skip the browser setup

If your goal is a clean page image or PDF rather than an interactive test, ScreenshotNeo provides a single request to capture a URL. Its capture options include selector waits, delays and network-idle waits, along with full-page capture and lazy-image loading.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for all options. The equivalent Python request is:

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)

And 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(`HTTP ${res.status}`);
require('node:fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));

Cookie and consent banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages and failed loads are not billed. An MCP server lets AI agents take screenshots, and 1,000 screenshots each month are free with no card; paid plans start at $5 for 3,000. Start with a free ScreenshotNeo account.

FAQ

Does page.goto() wait for everything?

No. It waits for the selected navigation milestone, load by default. Application fetches and rendering can continue afterward.

Should I always wait for load?

No. Choose the earliest milestone that supports the next operation, then assert the application state you require.

Is networkidle faster than an assertion?

Not reliably. Persistent or background requests can delay it, and network silence does not prove that a particular component is ready.

Do Playwright actions wait automatically?

Yes. Locator actions auto-wait for relevant actionability checks. Add explicit waits when your test depends on a navigation milestone or application-specific state.

How do I wait for a page before taking a screenshot?

Wait for the visual condition that matters, such as a chart or table being visible and a loading overlay being hidden. Then capture the page.