Handling Waits & Timeouts
Understand Playwright's auto-waiting mechanism and learn when to use explicit waits for network, state, and custom conditions.
Auto-Waiting Explained
Playwright's auto-waiting engine checks a checklist of conditions before executing any action: the element must be attached to the DOM, visible, stable (not animating), enabled, and — for inputs — not read-only. This happens automatically for every click(), fill(), or check() call. You do not need to sprinkle waitForSelector before every interaction the way you would in older frameworks.
npx playwright show-trace trace.zip) and look at the action timeline to see exactly which auto-wait condition was not met.Explicit Waits
Sometimes you need to wait for a specific network response before proceeding — for example, waiting for a POST request to complete before asserting that a record appeared in a table. Use page.waitForResponse() with a URL pattern, or page.waitForRequest() for the outgoing side. These methods return promises that resolve when the matching request is found, so you can await them alongside the triggering action using Promise.all().
Load States and Custom Conditions
page.waitForLoadState() lets you wait for 'load', 'domcontentloaded', or 'networkidle'. For custom conditions — such as waiting for a JavaScript variable to be set — use page.waitForFunction() which polls the page context until the provided expression returns a truthy value. Always prefer semantic waits over arbitrary sleep delays.
| Method | When to use |
|---|---|
| Auto-wait (implicit) | Before every action — always active |
| waitForResponse(url) | After clicking something that triggers an API call |
| waitForLoadState('networkidle') | After SPA navigation with lazy-loaded content |
| waitForFunction(expr) | Custom JS condition in the page context |
| locator.waitFor() | Wait for a specific element state |
import { test, expect } from '@playwright/test';
test('wait for API response after form submit', async ({ page }) => {
await page.goto('/orders/new');
await page.getByLabel('Product').fill('Widget A');
await page.getByLabel('Quantity').fill('3');
// Wait for both the click AND the response simultaneously
const [response] = await Promise.all([
page.waitForResponse(resp =>
resp.url().includes('/api/orders') && resp.status() === 201
),
page.getByRole('button', { name: 'Place Order' }).click(),
]);
const body = await response.json();
expect(body.orderId).toBeDefined();
// Wait for element to reach a specific state
await page.getByTestId('order-status').waitFor({ state: 'visible' });
// Custom JS condition — wait until total updates
await page.waitForFunction(() =>
document.querySelector('[data-testid="total"]')?.textContent !== '$0.00'
);
// Load state after SPA navigation
await page.getByRole('link', { name: 'History' }).click();
await page.waitForLoadState('networkidle');
await expect(page.getByRole('table')).toBeVisible();
});
import com.microsoft.playwright.*;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
class WaitsTest {
void waitForApiResponse(Page page) {
page.navigate("/orders/new");
page.getByLabel("Product").fill("Widget A");
page.getByLabel("Quantity").fill("3");
// Start waiting BEFORE clicking
Response response = page.waitForResponse(
resp -> resp.url().contains("/api/orders") &&
resp.status() == 201,
() -> page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Place Order"))
.click()
);
// Wait for element visibility
page.getByTestId("order-status")
.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE));
// Network idle after navigation
page.getByRole(AriaRole.LINK,
new Page.GetByRoleOptions().setName("History")).click();
page.waitForLoadState(LoadState.NETWORKIDLE);
assertThat(page.getByRole(AriaRole.TABLE)).isVisible();
}
}
from playwright.sync_api import Page, expect
def test_wait_for_api_response(page: Page):
page.goto("/orders/new")
page.get_by_label("Product").fill("Widget A")
page.get_by_label("Quantity").fill("3")
# Use context manager — starts waiting BEFORE clicking
with page.expect_response(
lambda r: "/api/orders" in r.url() and r.status == 201
) as resp_info:
page.get_by_role("button", name="Place Order").click()
response = resp_info.value
body = response.json()
assert "orderId" in body
# Wait for element state
page.get_by_test_id("order-status").wait_for(state="visible")
# Wait for custom JS condition
page.wait_for_function(
"document.querySelector('[data-testid="total"]')?.textContent !== '$0.00'"
)
# Network idle after SPA navigation
page.get_by_role("link", name="History").click()
page.wait_for_load_state("networkidle")
expect(page.get_by_role("table")).to_be_visible()
Written by PV
© 2026 All Rights Reserved