Navigation and waiting
Navigate pages, choose readiness signals, and replace sleeps with assertions that describe the product state.
Navigation is not the same thing as readiness. A browser can load a document while the application is still fetching data. A single-page app can change screens without a full document navigation. A link can update the URL before the important content appears.
Reliable tests separate those ideas. First, drive the browser to the right place. Then, wait for the product state the user needs.
The reader problem: the page changed, but is it ready?
The common failure looks like this: the test clicks a link, immediately checks a heading, and fails because the UI was still rendering. The tempting fix is sleep(1). That adds time but not certainty.
A better wait names the condition:
use function Playwright\Testing\expect;
$page->getByRole('link', ['name' => 'Billing'])->click();
expect($page)->toHaveURL('https://app.example.test/billing');
expect($page->getByRole('heading', ['name' => 'Billing']))->toBeVisible();
The URL proves the route changed. The heading proves the screen the user needs is visible.
Mental model: browser readiness vs product readiness
There are three useful readiness layers:
- Document navigation: the browser loaded or started loading a document.
- Client-side transition: the app changed route or state without a full reload.
- User-visible readiness: the specific heading, message, row, button, or result exists.
Browser load states help with the first layer. Assertions usually cover the third layer. Most application bugs show up between layer two and layer three, which is why a load event alone is often too weak.
Open a URL with goto()
Use goto() when the test starts from a direct URL or intentionally checks routing, redirects, cookies, headers, or server-rendered pages.
<?php
require __DIR__.'/vendor/autoload.php';
use Playwright\Playwright;
use function Playwright\Testing\expect;
$context = Playwright::chromium();
$page = $context->newPage();
$page->goto('https://example.com', [
'waitUntil' => 'domcontentloaded',
'timeout' => 10000,
]);
expect($page)->toHaveTitle('Example Domain');
expect($page->getByRole('heading'))->toHaveText('Example Domain');
$context->close();
Expected result: the browser reaches the page, the assertions wait for the expected title and heading, and the script exits.
The default waitUntil is a good starting point. Override it only when you know which browser state the page needs.
Choose the right load state
Common waitUntil values are:
| State | Use when | Risk |
|---|---|---|
load |
ordinary page loads | may wait for assets that are not relevant |
domcontentloaded |
server-rendered pages or fast startup checks | may return before app data appears |
networkidle |
rare flows where quiet network is the product signal | polling, analytics, streams, and preloads can delay or prevent it |
commit |
low-level checks where response start matters | usually too early for UI assertions |
Do not choose networkidle by habit. Modern apps often keep background traffic alive. Prefer a user-visible assertion unless quiet network is the behavior you actually need.
Let actions and assertions wait
Locator actions wait for actionability. Assertions retry. A normal flow should not need a manual wait between a click and a visible result:
$page->goto('https://app.example.test/login');
$page->getByLabel('Email')->fill('ada@example.com');
$page->getByLabel('Password')->fill('secret');
$page->getByRole('button', ['name' => 'Sign in'])->click();
expect($page)->toHaveURL('https://app.example.test/dashboard');
expect($page->getByRole('heading', ['name' => 'Dashboard']))->toBeVisible();
If the page updates without changing the URL, assert the content instead:
$page->getByRole('tab', ['name' => 'Invoices'])->click();
expect($page->getByRole('heading', ['name' => 'Invoices']))->toBeVisible();
expect($page->getByRole('row'))->toHaveCount(12);
The wait describes what the reader expects from the product, not how long the browser might need.
Use waitForURL() for route transitions
Use waitForURL() when a user action should land on a known route and you want to make that route boundary explicit:
$page->getByRole('link', ['name' => 'Billing'])->click();
$page->waitForURL('**/billing', ['timeout' => 10000]);
expect($page->getByRole('heading', ['name' => 'Billing']))->toBeVisible();
This is especially useful when redirects are part of the flow: login, checkout, OAuth, password reset, or deep links that normalize their URL.
Do not stop at the URL if the user needs content on the target page. URL and content assertions answer different questions.
This distinction matters in applications with redirects. A login may visit /login, /callback, and /dashboard before the user sees useful content. Waiting for the final URL explains the routing expectation. Asserting the dashboard heading explains the product expectation.
Use waitForLoadState() sparingly
waitForLoadState() is useful when the browser load state itself matters. For example, a PDF preview route, a full document reload, or a page that must finish parsing before the next script action.
$page->getByRole('link', ['name' => 'Open report'])->click();
$page->waitForLoadState('domcontentloaded');
If you find yourself adding waitForLoadState() after every click, the test is probably waiting for the wrong thing. Prefer assertions on the resulting UI.
The exception is a test whose subject is the document lifecycle itself: reload behavior, cache behavior, print routes, or full-page server responses. In those cases the load state is not a workaround; it is part of the behavior being tested.
Timeouts should explain exceptions
There are three useful timeout levels:
- per operation:
goto(..., ['timeout' => 10000]); - page default for actions and locators:
$page->setDefaultTimeout(5000); - navigation default:
$page->setDefaultNavigationTimeout(15000).
Keep defaults near test setup. Use per-operation timeouts for known exceptions.
A raised timeout should tell a story: "this report generation can take 20 seconds". A global timeout increase often hides slow or unstable behavior everywhere.
Debug failed navigation
When navigation fails, ask three questions in order:
- Where did the browser end up?
- Did the expected content render?
- Was the wait looking for the right condition?
Helpful evidence:
echo $page->url().PHP_EOL;
$page->screenshot(__DIR__.'/var/artifacts/navigation-failure.png');
A correct URL with missing content means navigation probably worked and readiness did not. A wrong URL means routing, redirects, auth state, or base URL should be checked first.
Common mistakes
Sleeping after navigation. It adds delay without naming readiness.
Asserting only the URL. A route can be correct while the important data is missing.
Using networkidle as a default. It often waits for noise or never arrives.
Changing global timeouts to fix one slow flow. Raise the timeout where the slow business event happens.
Waiting after the action instead of around the event. For popups, downloads, and some responses, register the wait before the action that triggers the event.
Go next
- Previous guide: Pages and frames
- Next guide: Actions and input
- Subject map: Waiting, Timeouts
- Debug failures: Failure analysis
- Copy a task: Wait for text to appear
- API reference: Page