Timeouts and retries
Use auto-waiting, assertion retries, and explicit timeouts without hiding real browser failures.
A timeout is not the cause of a failure. It is the moment Playwright PHP stopped waiting for something that never became true.
Good timeout handling starts with the question "what condition did not happen?" Bad timeout handling starts by adding more seconds everywhere. The goal is to wait for product state, keep exceptions local, and use retries only as a signal while you investigate.
Mental model: waits belong to conditions
Playwright PHP already waits in several places:
- locator actions wait for actionability;
- browser assertions retry;
- navigation methods wait for load states;
- explicit waits can target URLs, responses, selectors, functions, or locators.
Use those waits to describe the condition you need. Do not use time as a substitute for state.
use function Playwright\Testing\expect;
$page->getByRole('button', ['name' => 'Save'])->click();
expect($page->getByText('Saved'))->toBeVisible();
This waits for the user-visible result. It does not guess how long saving takes.
Avoid fixed sleeps
Fixed sleeps are usually both too short and too long:
// Bad: sometimes too short, always too long when the app is fast.
sleep(2);
Replace the sleep with the state the product promises:
expect($page->getByText('Report ready'))->toBeVisible();
If the page depends on a specific backend call, wait for the response and still assert the visible result:
$response = $page->waitForResponse('**/api/reports/export', ['timeout' => 15000]);
if (202 !== $response->status()) {
throw new RuntimeException('Export was not accepted.');
}
expect($page->getByText('Export started'))->toBeVisible();
The response proves the backend answer. The visible assertion proves the user saw the expected state.
Choose the right timeout level
There are three common levels:
$page->setDefaultTimeout(5000);
$page->setDefaultNavigationTimeout(15000);
$page->goto('https://app.example.test/reports', ['timeout' => 20000]);
Use them differently:
| Level | Use for | Risk |
|---|---|---|
| operation timeout | one known slow operation | easy to understand |
| page default timeout | a scenario with a different budget | can surprise readers if changed mid-test |
| navigation timeout | routes, redirects, reloads | should not hide missing content |
Keep default changes near setup. Use local timeouts for exceptions.
The number is a budget, not a fix. A short budget gives fast feedback when a normal control is missing. A longer budget is justified when the product operation itself is slow: report generation, payment authorization, import processing, or a queued job.
Use assertion timeouts for slow outcomes
When the product legitimately takes longer, make the budget visible on the assertion:
expect($page->getByText('Export complete'))
->withTimeout(30000)
->toBeVisible();
This tells the next reader that export completion is expected to be slow. It also keeps the rest of the test suite from waiting 30 seconds for normal UI.
For locator-specific waits, prefer assertions when you need to prove a result, and use Locator::waitFor() when you only need a state transition:
$page->getByText('Loading')->waitFor(['state' => 'hidden', 'timeout' => 10000]);
expect($page->getByRole('heading', ['name' => 'Results']))->toBeVisible();
The wait handles the transitional element. The assertion proves the final screen.
Classify the timeout before fixing it
Different timeout symptoms point to different fixes:
| Symptom | Likely cause | Better next step |
|---|---|---|
| element never appears | wrong page, missing data, failed request | screenshot, trace, network log |
| element exists but cannot be clicked | overlay, disabled state, animation, frame | inspect actionability and page state |
| URL never changes | navigation did not start, validation blocked submit | assert validation or wait for URL |
| assertion sees old text | app state did not update | inspect response and visible state |
| response never arrives | request was never sent or route pattern wrong | log requests before routing |
The fix should match the symptom. More time only helps when the condition is correct and genuinely slow.
Use retries as a diagnostic signal
Test-level retries can be useful in CI because they answer one question: "does this failure reproduce every time?" They do not make a flaky test reliable.
Use retries temporarily while you collect evidence:
- first failure: save trace, screenshot, logs, and failed request evidence;
- retry passes: suspect race, state leak, data dependency, or timing;
- retry fails the same way: suspect product bug, fixture issue, route mismatch, or environment drift.
Do not keep permanent retries around known flakes without tracking the root cause. A retry that hides a failure also hides the signal you need to fix it.
Debug a timeout with artifacts
When a timeout fails, collect evidence before changing waits:
echo $page->url().PHP_EOL;
$page->screenshot(__DIR__.'/var/playwright-artifacts/timeout.png');
If you use PlaywrightTestCaseTrait, setting PW_TRACE=1 records a trace and writes it on failure. For standalone scripts, start and stop tracing explicitly on the context.
Use the trace to answer:
- did the action run?
- did the page navigate?
- did the element exist?
- was it hidden, disabled, covered, or inside a frame?
- did a request fail before the assertion?
If the trace shows the page was already correct before the timeout, inspect the assertion. The locator may target the wrong frame, match several elements, or assert text that differs from what users actually see.
Common mistakes
Raising global timeouts first. This slows every failure and hides the one condition that was wrong.
Retrying the whole test instead of the condition. Browser assertions already retry the condition. Retrying the whole test changes setup, data, and timing.
Waiting for load state after every action. Most user actions need a visible assertion, not a document lifecycle wait.
Treating a timeout as a selector problem only. Selectors are one cause. Missing data, auth state, failed requests, and overlays are just as common.
Verification checklist
- No fixed sleeps for UI readiness.
- Assertions describe product state.
- Longer timeouts are local and justified.
- Navigation timeouts are not used to wait for app content.
- Retries are temporary or explicitly tracked.
- Timeout failures produce screenshot or trace evidence.
Go next
- Previous guide: Choosing assertions
- Next guide: Screenshots, video, and traces
- Subject map: Waiting, Timeouts, Retries
- Debug failures: Failure analysis
- Copy tasks: Poll until a condition is met