In Playwright, you usually do not need to wait for an element before interacting with it. Locator actions such as click() and fill() automatically wait for the relevant actionability conditions, while web-first assertions such as expect(locator).toBeVisible() retry until the expected condition is met or the assertion times out.
Avoid using page.waitForTimeout() as a synchronization mechanism. A fixed delay can be too short, causing flaky tests, or unnecessarily long, slowing down tests that were already ready to continue.
How does Playwright wait for elements automatically?
Before performing actions, Playwright runs actionability checks and automatically waits until the relevant checks pass.
For example, before locator.click(), Playwright checks that the locator resolves to exactly one element and that the element is visible, stable, enabled, and able to receive events.
|
Check |
What it means |
|---|---|
|
Visible |
The element has a non-empty bounding box and is not |
|
Stable |
The element has maintained the same bounding box for at least two consecutive animation frames. |
|
Receives events |
The element is the hit target rather than being covered by another element. |
|
Enabled |
The element is not disabled. |
|
Editable |
For actions that require editing, the element is enabled and not read-only. |
Different actions require different checks.
For example, click() checks visibility, stability, whether the element receives events, and whether it is enabled. fill() checks visibility, enabled state, and editability, but does not require the stability or receives-events checks.
This means you normally do not need:
await page.waitForTimeout(2000);
await page
.getByRole('button', { name: 'Submit' })
.click();
Instead, use:
await page
.getByRole('button', { name: 'Submit' })
.click();
Playwright waits until the button is actionable or the action times out.
What should you use instead of waitForTimeout()?
The right replacement depends on what you are actually waiting for.
|
If you are waiting for... |
Use... |
Why |
|---|---|---|
|
An element to become visible |
|
Retries until the expected UI state is reached. |
|
Text to change |
|
Waits for the actual text instead of an arbitrary delay. |
|
A spinner to disappear |
|
Continues as soon as loading has actually finished. |
|
A list to contain a certain number of items |
|
Retries the locator until the expected count is reached. |
|
An API response |
|
Waits for the relevant network event. |
|
A URL change |
|
Retries until the expected URL is reached. |
|
A non-DOM value |
|
Repeatedly evaluates an asynchronous value. |
|
A multi-step asynchronous condition |
|
Retries the complete block. |
|
An element before clicking it |
Usually nothing |
Playwright's locator action already auto-waits. |
The general rule is simple:
Wait for the condition you care about, not for an arbitrary amount of time.
Why can waitForTimeout() make Playwright tests flaky?
Consider this test:
await page
.getByRole('button', { name: 'Save' })
.click();
await page.waitForTimeout(2000);
expect(
await page.getByTestId('status').textContent()
).toBe('Saved');
The test assumes that two seconds is enough for the application to finish saving.
That creates two possible problems.
If the application takes 2.5 seconds, the test fails even though the application is working correctly. If the application takes 200 milliseconds, the test still waits the full two seconds.
A web-first assertion expresses the real requirement:
await page
.getByRole('button', { name: 'Save' })
.click();
await expect(
page.getByTestId('status')
).toHaveText('Saved');
Now the assertion retries until the status becomes Saved or the assertion timeout expires.
How do you wait for an element to be clickable in Playwright?
Usually, you do not need a separate wait.
This is enough:
await page
.getByRole('button', { name: 'Submit' })
.click();
Before clicking, Playwright automatically waits for the relevant actionability checks to pass.
Adding this is normally unnecessary:
await expect(
page.getByRole('button', { name: 'Submit' })
).toBeVisible();
await page
.getByRole('button', { name: 'Submit' })
.click();
If visibility is itself something your test needs to verify, the assertion can be meaningful. But if its only purpose is to make the subsequent click() work, the click already performs its own actionability checks.
Why can a Playwright click still fail?
Auto-waiting does not mean every click will succeed.
If a click fails or becomes flaky, investigate the condition preventing the action rather than adding a fixed delay.
Common causes include:
-
Another element covers the target. A cookie banner, modal backdrop, toast, or loading overlay may intercept the click.
-
The locator matches multiple elements. Playwright locators are strict for operations that imply a single target, so narrow the locator to the intended element.
-
The element keeps changing. Animations or application re-rendering can prevent the element from becoming stable.
-
The element remains disabled. Wait for or assert the application state that should enable it.
-
The timeout is too short for the application behavior. This can become more visible in slower CI environments.
-
The test uses
force: true. Forced actions bypass some actionability checks instead of resolving the condition that caused them to fail.
For example, if a loading overlay prevents a button from receiving events, do not work around the problem with:
await page.waitForTimeout(3000);
await page
.getByRole('button', { name: 'Submit' })
.click();
Wait for the actual application state:
await expect(
page.getByTestId('loading-spinner')
).toBeHidden();
await page
.getByRole('button', { name: 'Submit' })
.click();
When should you use locator.waitFor()?
Use locator.waitFor() when you need to wait for an element state without making an assertion.
For example:
const confirmation = page.getByText('Order submitted');
await confirmation.waitFor({
state: 'visible'
});
locator.waitFor() can wait for the element to become:
-
attached -
detached -
visible -
hidden
If the condition represents something the test is supposed to verify, prefer a web-first assertion:
await expect(
page.getByText('Order submitted')
).toBeVisible();
That expresses the test expectation directly.
Should you use page.waitForSelector()?
page.waitForSelector() is still available, but locator-based APIs are generally a better fit for modern Playwright tests.
Instead of:
await page.waitForSelector('#success');
prefer an assertion when the element represents an expected outcome:
await expect(
page.getByTestId('success')
).toBeVisible();
Or use locator.waitFor() when you specifically need a wait without an assertion:
await page
.getByTestId('success')
.waitFor();
Locators are a central part of Playwright's auto-waiting and retry behavior and are re-evaluated when used.
Should you use page.waitForLoadState('networkidle')?
Be careful about using networkidle as a general indication that an application is ready.
Modern web applications may continuously poll servers, stream data, or maintain long-running network connections. More importantly, the absence of network activity does not necessarily mean that the UI state your test needs is ready.
Prefer waiting for the user-visible condition that matters.
For example, instead of relying on:
await page.waitForLoadState('networkidle');
you might assert that the actual page content is ready:
await expect(
page.getByRole('heading', { name: 'Orders' })
).toBeVisible();
How do you wait for an API response?
When the next step depends specifically on a network request, wait for that request rather than adding a fixed delay.
Register the response wait before triggering the action so that the test does not miss a fast response:
const responsePromise = page.waitForResponse(
response =>
response.url().includes('/api/orders') &&
response.status() === 200
);
await page
.getByRole('button', { name: 'Place order' })
.click();
await responsePromise;
If what actually matters is the resulting UI state, assert that state as well:
await expect(
page.getByTestId('order-status')
).toHaveText('Order placed');
How do you wait for text to change?
Use a web-first assertion rather than repeatedly reading textContent() yourself.
Instead of:
await page.waitForTimeout(2000);
expect(
await page.getByTestId('status').textContent()
).toBe('Saved');
use:
await expect(
page.getByTestId('status')
).toHaveText('Saved');
Playwright retries the assertion until the expected text appears or the assertion times out.
How do you wait for a spinner to disappear?
Wait for the actual loading indicator to become hidden:
const spinner = page.getByTestId('loading-spinner');
await expect(spinner).toBeHidden();
This is more deterministic than guessing how long loading should take:
await page.waitForTimeout(3000);
If loading finishes in 300 milliseconds, the test can continue immediately. If it takes longer than three seconds but remains within the configured assertion timeout, the test can continue waiting.
When is waitForTimeout() acceptable?
A fixed timeout can be useful temporarily while debugging a test locally.
For example:
await page.waitForTimeout(3000);
can give you time to inspect the page during development.
It should not normally be used to synchronize committed tests with application behavior. Replace the delay with the specific element, network, URL, or application state the test actually depends on.
Example: replacing fixed waits with Playwright auto-waiting
Before
await page.click('#checkout');
await page.waitForTimeout(3000);
await page.click('#confirm');
await page.waitForTimeout(3000);
expect(
await page.textContent('.status')
).toBe('Order placed');
This test always waits at least six seconds and can still fail if the application takes longer than expected.
After
await page
.getByRole('button', { name: 'Checkout' })
.click();
await page
.getByRole('button', { name: 'Confirm order' })
.click();
await expect(
page.getByTestId('status')
).toHaveText('Order placed');
The rewritten version:
-
Relies on Playwright's auto-waiting for interactions.
-
Uses user-facing locators instead of CSS selectors where appropriate.
-
Replaces fixed delays with an auto-retrying assertion.
-
Continues as soon as the expected condition is satisfied.
How does Leapwork Play help with Playwright waiting and flaky tests?
Correct waiting patterns make individual Playwright tests more deterministic. At team scale, however, another problem appears: understanding which tests repeatedly fail, whether a failure is new, and whether the same test has been unstable across previous runs.
Leapwork Play complements the Playwright test itself by providing execution history and trends across test runs. This helps teams investigate recurring test reliability problems rather than looking at each failed run in isolation.
Play can also help avoid introducing fixed waits when Playwright code is created using Play's recorder or AI.
The important distinction is that Playwright's own auto-waiting and assertions remain responsible for synchronization inside the test. Play provides additional capabilities around creating, running, and understanding those tests.
Frequently asked questions
Does Playwright wait for elements automatically?
Yes. Playwright locator actions automatically perform the actionability checks relevant to the action and wait until those conditions pass or the action times out.
What should I use instead of page.waitForTimeout()?
Wait for the condition the test actually needs. Common replacements include expect(locator).toBeVisible(), toHaveText(), toBeHidden(), page.waitForResponse(), and Playwright's built-in auto-waiting for actions.
How do I wait for an element to appear in Playwright?
If its appearance is an expected test outcome, use:
await expect(locator).toBeVisible();
If you only need to wait without asserting the behavior, use:
await locator.waitFor({
state: 'visible'
});
How do I wait until an element is clickable?
Usually, call click() directly:
await locator.click();
Playwright automatically waits for the actionability conditions required for the click.
What is the difference between locator.waitFor() and expect(locator).toBeVisible()?
Both can wait for visibility, but they express different intentions. Use expect(locator).toBeVisible() when visibility is something the test should verify. Use locator.waitFor() when you need synchronization without making visibility a test assertion.
Should I use page.waitForSelector()?
For modern Playwright tests, prefer locators. Use a web-first assertion when verifying a condition or locator.waitFor() when you only need to wait.
Why does my Playwright test pass locally but fail in CI?
CI environments can expose timing problems because the application or test environment may behave differently or take longer. Before increasing timeouts, check whether the test is waiting for the actual application condition and whether the locator identifies the intended element.
Is force: true a good way to fix a flaky click?
Usually not. force: true bypasses some of Playwright's normal actionability checks. Investigate why the element cannot satisfy those checks before forcing the interaction.
When should I use waitForTimeout()?
Primarily for temporary local debugging. For normal test synchronization, wait for the actual condition instead of a fixed amount of time.