Auto-waiting, timeouts and flakiness
Understand the actionability checks, use web-first assertions, and replace every hard sleep with a condition that is actually being waited on.
What auto-waiting actually checks
Before an action, Playwright waits for a specific set of conditions and retries until they hold or the timeout expires. Knowing these checks turns 'it is flaky' into a specific question: which condition never became true?
| Check | Means |
|---|---|
| Visible | Non-empty box, not visibility: hidden |
| Stable | Bounding box unchanged across two animation frames |
| Receives events | The hit test at the target point returns this element |
| Enabled | Not disabled, not aria-disabled |
| Editable | Not readonly, for fill and similar actions |
| Attached | Present in the DOM, for actions that do not need visibility |
// wait for the state you actually depend on, not for time to pass
const banner = page.getByRole("alert");
await banner.waitFor({ state: "visible" });
await expect(banner).toHaveText("Saved");
// states: attached, detached, visible, hidden
await page.getByTestId("spinner").waitFor({ state: "hidden" });
// page-level conditions
await page.waitForLoadState("domcontentloaded");
await page.waitForURL("**/dashboard");
await page.waitForResponse((r) => r.url().includes("/api/orders") && r.ok());⚠️
waitForTimeout is the single largest source of flaky suites. It converts a fast machine into a slow one and still fails on a slow machine, because it waits a fixed amount rather than for the condition. There is almost always a condition you can name - wait for that.Web-first assertions
// retried until it passes or the expect timeout expires
await expect(page.getByRole("heading")).toHaveText("Invoices");
await expect(page.getByRole("listitem")).toHaveCount(3);
await expect(page.getByRole("button", { name: "Pay" })).toBeEnabled();
await expect(page.getByLabel("Email")).toHaveValue("ada@example.com");
await expect(page.getByTestId("total")).toContainText("42.00");
await expect(page).toHaveURL(/\/invoices\/\d+/);
await expect(page).toHaveTitle(/Invoices/);
// negation also retries, which is usually what you want
await expect(page.getByText("Loading")).toBeHidden();- A web-first assertion is a retrying assertion. A plain
expect(await locator.textContent()).toBe(...)evaluates once and races the page. - Never read a value out of the page and assert on the variable when you can assert on the locator.
expect.pollretries any function, which is how you assert on things that are not locators.expect.toPassretries a block, useful for a multi-step interaction that is inherently racy.
// polling something that is not a locator
await expect.poll(async () => {
const res = await page.request.get("/api/orders");
return (await res.json()).length;
}, { timeout: 10_000 }).toBe(3);
// retrying a whole block
await expect(async () => {
await page.getByRole("button", { name: "Refresh" }).click();
await expect(page.getByRole("row")).toHaveCount(4);
}).toPass({ timeout: 15_000 });Timeout budgets
// playwright.config.ts
export default defineConfig({
timeout: 30_000, // per test
expect: { timeout: 5_000 }, // per assertion
use: {
actionTimeout: 10_000, // per action
navigationTimeout: 15_000, // per navigation
},
});test("imports a large file", async ({ page }) => {
test.slow(); // triples the timeout for this test
test.setTimeout(120_000); // or set it explicitly
});- Keep the global timeout tight; a suite has a slowest test, and a generous global value hides the fact that most tests finish in a second.
- If a single test needs more time, set it on that test with a comment explaining why.
- A timeout is a budget, not a fix. When one test needs triple the budget, ask which condition is missing.
- Check the trace before raising a timeout: the failing step is usually visible in the last frame.
FAQ
Why is my test flaky only in CI?
CI machines are slower and more loaded, so races that usually win start losing. The cause is almost always a missing wait - typically an assertion on a value read out of the page, or an interaction that starts before a network response has landed. Replacing hard sleeps and value-reads with web-first assertions fixes most of it.
How do I wait for an animation to finish?
Wait for the thing the animation produces: the element becoming visible, the class changing, or the attribute appearing. Alternatively set
animations: 'disabled' for screenshots. Waiting for a duration is the one approach that cannot be made reliable.Related
Actions and user interactions Debugging with UI mode, codegen and inspector
Last refreshed 2026-09-18.