TL;DR
Wait on a signal the app gives you: an element reaching a state, a URL, a response or a value in your backend.
- Delete wait-for-element lines before actions, because a wait-for-element line before a click repeats the wait the click already does.
A test clicks Save, sleeps for two seconds and then checks for the confirmation. On your laptop the save finishes well inside the sleep, so the test passes every time. On a CI runner that shares its CPU with other jobs, the save sometimes takes longer than the sleep. The build goes red, and the error points at the check instead of the sleep.
The sleep was never a wait. It was a guess about timing, and CI is where those guesses get tested. Most of the time, web pages show information asynchronously, and the Playwright Page API docs say plainly that tests that wait for time are inherently flaky.
The usual reaction to the red build makes things worse. Someone raises the sleep, bumps a timeout or turns on retries. Each of those turns a bad wait into a slow pass, and the race is still there for the next slow runner.
The fix is to wait on a signal the app gives you: an element reaching a state, a URL, a response or a value in your backend. Then leave the default timeouts alone and check that flake rates drop.
How auto-waiting works
Most explicit waits in a Playwright suite wait for something Playwright already waits for. Before an action, Playwright waits for the actionability checks to pass, and the action fails with a TimeoutError if they don't pass within the timeout. One of those checks is whether the target element receives the click.
Because Playwright waits for target elements to become actionable, you can interact with the page at any moment. A wait-for-element line before a click repeats the wait the click already does. Delete it. Auto-waiting is built into every action. An explicit wait is a line you add, and it belongs only where you need a condition that no action and no assertion checks.
When auto-waiting looks like it failed after a modal opens or a form submits, the click usually did its job. The test broke on the next step, because nothing waited for the modal to close or the URL to change. That is a wait on the wrong condition. Fix the condition, and leave the click alone.
Two habits undo auto-waiting. The first is checking visibility before you act. isVisible() doesn't wait at all: it checks the locator and returns immediately. To assert that an element is visible, the docs advise expect(locator).toBeVisible() to avoid flakiness. The second is force. On locator.click(), force turns off the non-essential actionability checks, including the check that the element receives the click. If an overlay covers the button, Playwright no longer checks that the button gets the click, so the test moves on whether or not the click did anything.
Anti-pattern
Visibility checks and forced clicks
In this example, the test checks for the consent dialog, dismisses it if it shows, and forces the next click.
// Anti-pattern: isVisible() returns at once, and force skips the check that Save receives the click
import { test, expect } from '@playwright/test';
test('save notification settings', async ({ page }) => {
await page.goto('/settings/notifications');
if (await page.getByRole('dialog', { name: 'Cookie consent' }).isVisible()) {
await page.getByRole('button', { name: 'Accept all' }).click();
}
await page.getByLabel('Email alerts').check();
await page.getByRole('button', { name: 'Save' }).click({ force: true });
expect(await page.getByText('Settings saved').isVisible()).toBe(true);
});
If the dialog is still rendering when isVisible() runs, the branch skips it, and the forced click goes to a button the dialog covers. The last line has the same flaw: it looks for the toast once and fails if the toast is a moment late.
Fix
The test should know whether the dialog appears. Set up the test environment so it always does (or never does), then use plain clicks and a web-first assertion on the result.
import { test, expect } from '@playwright/test';
test('save notification settings', async ({ page }) => {
await page.goto('/settings/notifications');
// The test environment always shows the consent dialog, so the test always dismisses it
await page.getByRole('button', { name: 'Accept all' }).click();
await page.getByLabel('Email alerts').check();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Settings saved')).toBeVisible();
});
Each click waits until its target is actionable. The assertion keeps checking until the toast appears or its timeout runs out.
Web-first assertions as waits
An explicit element wait followed by a check is two lines doing one job. A web-first assertion re-fetches the element and checks it again and again until the condition holds or the timeout runs out. So await expect(locator).toBeVisible() waits for the element and proves it's there in one line. The Page API marks page.waitForSelector as discouraged and says to use web assertions that assert visibility, or a locator-based locator.waitFor(), instead. It also covers content the page shows asynchronously: the assertion re-fetches the element and checks it over and over, until the condition is met or until the timeout is reached.
Auto-retrying assertions have their own timeout, separate from the test timeout, and the assertions docs let you pass it to a single assertion or set it once through the config's expect value. Pass a timeout to the one assertion that needs longer, and leave the config's value at its default. Raising the config's expect timeout gives every auto-retrying assertion that doesn't set its own timeout more time, and each one retries until it passes or that timeout is reached, so a failing one takes longer to fail.
The harder part is choosing what to assert. Assert the final state the user sees, not a signal on the way there.
Anti-pattern
Waiting for the spinner to go
The test waits for the loading spinner to be hidden, then counts the rows.
// Anti-pattern: the spinner can be gone before the rows render
import { test, expect } from '@playwright/test';
test('orders page lists orders', async ({ page }) => {
await page.goto('/orders');
await expect(page.locator('.spinner')).toBeHidden();
const rowCount = await page.getByRole('row').count();
expect(rowCount).toBeGreaterThan(1);
});
If the app hides the spinner when the request finishes and renders the rows afterwards, there is a gap between the two. On a fast laptop the gap is too short to matter. Most of the time, web pages show information asynchronously, and the assertions guide warns that using non-retrying assertions such as this count check can lead to a flaky test.
Fix
Assert state, act, assert outcome. Assert that the control is ready, act on it, then assert what the user should see, including the rendered data. Every step waits on something real, and none of them depends on how fast the spinner leaves.
import { test, expect } from '@playwright/test';
test('refresh shows the latest orders', async ({ page }) => {
await page.goto('/orders');
const refresh = page.getByRole('button', { name: 'Refresh' });
await expect(refresh).toBeEnabled();
await refresh.click();
await expect(page.getByText('Orders updated')).toBeVisible();
await expect(page.getByRole('row', { name: /ORD-1042/ })).toBeVisible();
});
The two toBeVisible assertions wait for the outcome itself: the confirmation and the row the test cares about. If the spinner does matter, say because it covers the next button, assert toBeHidden() on it, and still assert the data afterwards.
Replacing waitForTimeout
The Page API says tests that wait for time are inherently flaky and that page.waitForTimeout() should only be used for debugging, and it recommends waiting on signals such as network events or selectors becoming visible instead. Name the condition and the replacement follows: the toast appeared, the URL changed, the response came back, the job finished.
A hard wait costs you twice. It adds its full length to every passing run, because it waits just as long when the app was fast. And it still fails whenever CI is slower than the guess. A web-first assertion ends as soon as its condition holds and gives up only at the timeout.
In one bug report, a maintainer found that a sleep had no effect because the call was missing its await, not because of a Playwright bug, and he added that you should never use that call in a test (#33012). The Page API gives the same advice: use Locator actions and web assertions, which wait automatically.
Anti-pattern
A longer sleep after a CI failure
After a CI failure, the usual edit to a test like this one is to raise the number.
// Anti-pattern: a fixed 3-second guess, then a check that looks only once
import { test, expect } from '@playwright/test';
test('apply discount code', async ({ page }) => {
// Relative URL: assumes use: { baseURL: 'http://localhost:3000' } in playwright.config.ts
await page.goto('/cart');
await page.getByLabel('Discount code').fill('SPRING10');
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForTimeout(3000);
expect(await page.getByText('Discount applied').isVisible()).toBe(true);
});
page.waitForTimeout() waits for the given timeout in milliseconds. The check after the sleep doesn't retry, so it gets exactly one look.
Fix
Delete the sleep and assert the condition it was guessing at.
import { test, expect } from '@playwright/test';
test('apply discount code', async ({ page }) => {
await page.goto('/cart');
await page.getByLabel('Discount code').fill('SPRING10');
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByText('Discount applied')).toBeVisible();
});
The assertion passes the moment the message appears and fails only after the expect timeout.
Navigation and network waits
Some conditions don't show up on screen. Clicking an element can trigger multiple navigations. A form submit can end in a client-side redirect, and an API call can finish before anything visible changes. Playwright has explicit waits for these: page.waitForURL() waits for the main frame to navigate to a given URL, and page.waitForResponse() returns the matched response.
When a click may trigger multiple navigations, the navigation docs recommend waiting explicitly with page.waitForURL() for a specific URL. Use the URL you expect to land on, not any URL change.
For a response, start waiting before the action. The page.waitForResponse example in the docs starts waiting before the click and doesn't await the promise at that point. You await it after the click. The docs' example says it in a comment: "Start waiting for response before clicking. Note no await." Reversing that order is the click-then-wait race.
import { test, expect } from '@playwright/test';
test('placing an order redirects to its page', async ({ page }) => {
// Relative URL: assumes use: { baseURL: 'http://localhost:3000' } in playwright.config.ts
await page.goto('/checkout');
// Start waiting before the click. No await yet.
const orderResponse = page.waitForResponse(
(response) =>
response.url().includes('/api/orders') && response.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Place order' }).click();
const response = await orderResponse;
expect(response.ok()).toBe(true);
await page.waitForURL('**/orders/*');
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
The predicate matches the order request by URL and method, so an unrelated call can't satisfy the wait.
page.waitForLoadState is usually unnecessary, because Playwright auto-waits before every action. Don't add it after every goto out of habit, whatever state you pass.
The networkidle state is the one to remove. The docs mark it discouraged: it waits until there are no network connections for at least 500 ms, and the docs say to rely on web assertions to assess readiness instead. A quiet network doesn't mean the page is ready. A page that polls an endpoint or sends analytics beacons more often than every 500 ms never gets that quiet window.
Anti-pattern
Networkidle as 'page ready'
The example below uses networkidle.
// Anti-pattern: networkidle measures network silence, and background polling can keep it from arriving
import { test } from '@playwright/test';
test('open reports from the dashboard', async ({ page }) => {
// Relative URL: assumes use: { baseURL: 'http://localhost:3000' } in playwright.config.ts
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.getByRole('link', { name: 'Reports' }).click();
});
On a dashboard with background polling, the wait runs long or never ends. When it does pass, it says nothing about whether the dashboard rendered.
Fix
Assert the heading the user sees first.
import { test, expect } from '@playwright/test';
test('open reports from the dashboard', async ({ page }) => {
// Relative URL: assumes use: { baseURL: 'http://localhost:3000' } in playwright.config.ts
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.getByRole('link', { name: 'Reports' }).click();
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
});
Waiting on state outside the page
Sometimes the condition lives in your backend: an export job, an email queue, a search index. There's no element to assert on yet, and the old answer is a loop that sleeps and checks. expect.poll replaces that loop: it converts any synchronous expect into an asynchronous polling assertion.
By default, expect.poll polls at intervals of 100, 250, 500 and 1000 ms. Those defaults suit a fast endpoint. For a slow job, pass your own intervals so the test doesn't hammer the API, and give it a timeout. The assertions docs show both options.
import { test, expect } from '@playwright/test';
test('CSV export completes', async ({ page, request }) => {
// Relative URLs: assumes use: { baseURL: 'http://localhost:3000' } in playwright.config.ts,
// which page.goto() and the request fixture both respect
await page.goto('/reports');
await page.getByRole('button', { name: 'Export CSV' }).click();
await expect
.poll(
async () => {
const response = await request.get('/api/exports/latest');
const body = await response.json();
return body.status;
},
{
// Exports are slow: poll less often than the defaults
intervals: [1_000, 2_000, 5_000],
timeout: 20_000,
},
)
.toBe('completed');
});
The poll runs inside the test function, so its time counts toward the test timeout. A poll that needs longer than the test's budget needs a longer test timeout too.
When the check involves actions, such as searching again until the index catches up, toPass retries a whole block. By default toPass has a timeout of 0 and doesn't respect a custom expect timeout.
Anti-pattern
ToPass without a timeout
By default toPass has timeout 0 and does not respect a custom expect timeout. Time spent by the test function is included in the test timeout.
Fix
Give every toPass an explicit timeout that is shorter than the test's.
import { test, expect } from '@playwright/test';
test('new product shows up in search', async ({ page, request }) => {
// Relative URLs: assumes use: { baseURL: 'http://localhost:3000' } in playwright.config.ts
await request.post('/api/products', { data: { name: 'Trail Runner 2' } });
await page.goto('/search');
await expect(async () => {
await page.getByRole('searchbox').fill('Trail Runner 2');
await page.getByRole('button', { name: 'Search' }).click();
await expect(
page.getByRole('listitem').filter({ hasText: 'Trail Runner 2' }),
).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
});
The inner assertion's timeout keeps each attempt short, and the outer toPass timeout caps the whole retry.
The timeout stack
CI failures make sense once you know which timeout fired. The test timeout is 30 seconds by default, and it includes the time spent in the test function, fixture setup and beforeEach hooks. Every wait inside the test spends from that budget.
Auto-retrying assertions have a separate timeout, 5 seconds by default, unrelated to the test timeout. A failing auto-retrying assertion therefore fails on its own timeout, 5 seconds by default.
Actions work differently. actionTimeout defaults to 0, which means no timeout for each action. A click stuck on actionability doesn't fail on its own: it keeps waiting until the 30-second test timeout ends the test. So when a CI failure says the test timed out, look for the action that was still waiting, not the assertion. The timeouts docs call the action and navigation timeouts low-level settings you shouldn't need to change, and say that if your tests are flaky, the fix is very likely somewhere else. For each kind of timeout error in turn, our checklist for debugging Playwright timeouts goes step by step.
Anti-pattern
Raising timeouts until CI passes
After a red build, the config gets a test timeout four times the default and gives every assertion 30 seconds instead of 5.
// Anti-pattern: the race is still there, and every bad wait now takes up to two minutes to fail
import { defineConfig } from '@playwright/test';
export default defineConfig({
timeout: 120_000,
expect: { timeout: 30_000 },
});
The build may go green, but no race is gone. The default timeout for async expect matchers is 5000 ms.
Fix
Leave the global defaults alone and give the few slow tests their own budget. test.slow() gives a test triple the default timeout, and a per-assertion timeout covers the one check that is slow.
import { defineConfig } from '@playwright/test';
export default defineConfig({
// The defaults, written out so a change to them shows up in review
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
// 0 means no per-action limit: a stuck click runs until the test timeout
actionTimeout: 0,
},
});
import { test, expect } from '@playwright/test';
test('generate yearly invoice PDF', async ({ page }) => {
test.slow();
// Relative URL: assumes baseURL is set in playwright.config.ts
await page.goto('/invoices');
await page.getByRole('button', { name: 'Generate yearly PDF' }).click();
await expect(page.getByText('PDF ready')).toBeVisible({ timeout: 30_000 });
});
Why waits flake in CI
A test that passes locally and times out in CI usually has a race that the faster machine was hiding. Treat it as a bug in the wait, not as slowness to cover with a longer timeout. In #24403, a maintainer replied that when something works locally and does not work in Docker, it is likely due to a race condition in the test. The reporter later pointed to their own focus handling and a hydration bug in their app.
CI makes those races more likely to show up. Playwright recommends one worker in CI for stability and reproducibility, so that each test gets the full system resources. Running tests sequentially gives each test the full system resources and avoids potential conflicts. Every page gets slower, and gaps that a fast laptop closed stay open.
Playwright categorizes a test that failed on the first run but passed when retried as flaky. Failing tests are not retried by default. With retries on, a failing test is retried until it passes or hits the maximum, so a race that loses once and wins on the next attempt ends green. failOnFlakyTests makes the run exit with an error if any test is marked flaky, and the docs call it useful on CI.
Anti-pattern
Retries as a stabilizer
This config gives a test that loses its race up to three more attempts on CI before it counts as failed.
// Anti-pattern: the race passes on the second try and ships
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 3 : 0,
});
The test goes green on a later attempt, the race ships, and the cost moves into longer pipelines.
Fix
Run one worker on CI, keep retries low, and turn on failOnFlakyTests so a race that passes on retry still fails the build.
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: process.env.CI ? 1 : undefined,
retries: process.env.CI ? 1 : 0,
failOnFlakyTests: !!process.env.CI,
});
The retry tells you the outcome isn't deterministic, and the flag keeps that test from passing the build.
Reading the failure
For CI failures, the best practices recommend the trace viewer over videos and screenshots. Open the trace from the failed run and look for the action or assertion that was still waiting when the timeout hit. That step is the wait to fix. Our walkthrough of reading a locator timeout in the trace viewer shows where to look.
Removing hard waits at scale
Fixing one test is easy. A suite with hundreds of sleeps needs a plan, or the migration adds as many races as it removes.
Start with a list. Before changing anything, find every page.waitForTimeout and networkidle call:
grep -rnE "waitForTimeout|networkidle" --include="*.ts" --exclude-dir=node_modules .
grep -rcE "waitForTimeout|networkidle" --include="*.ts" --exclude-dir=node_modules . | grep -v ":0$" | sort -t: -k2 -nr
The first command lists each call with its file and line. The second counts matching lines per file, busiest file first. Then group the calls by the condition each one was guessing at, which the line before the sleep usually gives away.
| The sleep was guessing that | Replace it with |
|---|---|
| An element appeared or changed | A web-first assertion on that element |
| The URL changed | page.waitForURL() with the expected URL |
| An API call finished | page.waitForResponse, started before the action and awaited after it |
| A backend job or record reached a state | expect.poll over the API |
| The page loaded | A web-first assertion on a visible heading, not networkidle |
A sleep right before an action, with nothing after it that depends on the sleep, can usually go without a replacement, because the action auto-waits.
Replace one group at a time, and keep failOnFlakyTests on in CI throughout. Playwright marks a test that fails on the first run but passes when retried as flaky, and failOnFlakyTests makes the run exit with an error if any tests are marked as flaky.
Measuring flake rate before and after
One green run proves nothing about a race. A race that loses only on a slow runner passes most runs anyway.
Compare each test's flake rate over equal periods before and after you removed its hard waits. If you don't track it yet, you can compute a flake rate from your own Playwright reports. Look at per-test numbers, not the suite's pass rate, because one fixed test disappears into a suite average. Keep the periods comparable in run count too, since a quiet week after the change looks stable for the wrong reason.
Currents Test Explorer shows each test's Flakiness Rate (the percentage of its executions with a non-deterministic outcome), and its % Flaky Trend compares two equal periods, such as the last 30 days against the previous 30, where a negative value means the test became more stable and a positive one means it got flakier.
To decide which waits to fix first, rank them by how often they fail across CI runs. Currents Error Explorer groups failures by Action, such as toBeVisible, click or waitForResponse, and grouping by Category and Branch shows whether timeouts or assertions cause most of them. Fix from the top: the action that fails most is the wait that costs you most.
Where waits stop helping
Some flakes aren't wait problems, and no wait fixes them. If a click on an enabled button sometimes does nothing because the page hasn't hydrated, the navigation docs say the right fix is to keep interactive controls disabled until hydration finishes. A test-side wait can only guess when hydration is done, which is the same guess page.waitForTimeout made.
When a test stays flaky after its waits are fixed, the timeouts docs point the same way: the low-level timeouts are not the fix, and the solution is very likely elsewhere. Look at the app, the test data and the environment before you touch another timeout.
Don't add explicit waits by default. Actions auto-wait for their actionability checks to pass, and web-first assertions re-check the element until the condition is met or the timeout is reached. Keep page.waitForURL(), page.waitForResponse and expect.poll for the signals an assertion on the page can't see. Keep failOnFlakyTests on in CI.
When you catch yourself typing a number of milliseconds, name the condition instead.
Frequently asked questions
How do I replace waitForTimeout in Playwright?
Delete the sleep and assert the condition it was guessing at, such as await expect(page.getByText('Discount applied')).toBeVisible(). The assertion passes the moment the message appears and fails only after the expect timeout.
How do I wait for an element to be visible in Playwright?
Use await expect(locator).toBeVisible(), which waits for the element and proves it's there in one line. A web-first assertion re-fetches the element and checks it again and again until the condition holds or the timeout runs out. The Page API marks page.waitForSelector as discouraged, as Web-first assertions as waits notes.
How do I wait for a page to load in Playwright?
Assert the heading the user sees first with a web-first assertion instead of waiting for networkidle. The docs mark networkidle discouraged: it waits until there are no network connections for at least 500 ms, and the docs say to rely on web assertions to assess readiness instead. page.waitForLoadState is usually unnecessary, because Playwright auto-waits before every action, as Navigation and network waits explains.
How do I wait for a network response in Playwright?
Start page.waitForResponse before the action without awaiting it, then await the promise after the click. The docs' example says it in a comment: "Start waiting for response before clicking. Note no await." A predicate that matches the request by URL and method keeps an unrelated call from satisfying the wait, as Navigation and network waits shows.
Why do Playwright tests pass locally but time out in CI?
A test that passes locally and times out in CI usually has a race that the faster machine was hiding. Treat it as a bug in the wait, not as slowness to cover with a longer timeout, as Why waits flake in CI argues.
What are the default timeouts in Playwright?
The test timeout is 30 seconds by default, and it includes the time spent in the test function, fixture setup and beforeEach hooks. Auto-retrying assertions have a separate timeout, 5 seconds by default. actionTimeout defaults to 0, which means no timeout for each action, as The timeout stack explains.
Do retries fix flaky Playwright waits?
No. With retries on, a failing test is retried until it passes or hits the maximum, so a race that loses once and wins on the next attempt ends green. Run one worker on CI, keep retries low, and turn on failOnFlakyTests so a race that passes on retry still fails the build, as Retries as a stabilizer shows.




