Playwright Waits: Auto-Waiting, Assertions, and Best Practices

Playwright Waits control how tests handle page loading and UI changes. Explore auto-waiting, assertions, timeouts, and best practices.

Written by Vinayak Mirani Vinayak Mirani
Reviewed by Ashwani Pathak Ashwani Pathak
Last updated: 16 January 2026 21 min read

Key Takeaways

  • Choose the wait based on what the app needs next, such as a visible element, changed URL, or completed API response.
  • Use Playwright auto-waiting for normal actions, and web-first assertions when checking expected UI results.
  • Replace hard waits with specific waits that confirm the final user-facing state.

Playwright Waits: Auto-Waiting, Assertions, and Best Practices

Waiting is one of the main reasons Playwright tests either stay stable or become flaky. Playwright handles many waits automatically, but that does not mean every timing problem is solved by default.

Asynchronous timing and wait issues are the root cause of roughly 45% of all flaky tests. I have seen this happen often in Playwright suites: a test passes locally, fails in CI, and then passes again without any code change.

In this article, I will explain how Playwright wait works, which wait methods are useful, which ones to avoid, and how to fix common flaky wait problems.

What is Playwright Wait?

Playwright wait is the way a test pauses until the application reaches the right state. That state could be a visible button, a completed API response, a changed URL, a loaded page, or a custom condition inside the browser.

What is Playwright Wait

In Playwright, waits usually fall into four groups:

  • Element waits: Wait for an element to become visible, hidden, attached, or detached.
  • Assertion waits: Wait until the UI matches the expected condition.
  • Navigation waits: Wait for a URL change or page load state.
  • Network waits: Wait for a specific request or response.

For most tests, you should not start by adding manual waits. Playwright already waits before many actions. Manual waits should only be added when the test needs a signal Playwright cannot infer from the action itself.

How Playwright Auto-Waiting Works

Playwright auto-waiting means Playwright waits for an element to be ready before performing an action on it. This happens automatically for actions like click(), fill(), check(), selectOption(), and hover().

For example, when you call:

await page.getByRole('button', { name: 'Submit' }).click();

Playwright does not click the button immediately. It first checks whether the element is ready for interaction.

Before clicking, Playwright checks that the element is:

  • Visible: The element is displayed on the page.
  • Stable: The element is not moving or animating.
  • Enabled: The element is not disabled.
  • Able to receive events: The element is not covered by another element.
  • Uniquely resolved: The locator points to one matching element.

How Playwright Auto Waiting Works checks whether the element is ready for interaction

This is why you usually do not need to add a wait before a normal click or form action. In most cases, this is unnecessary:

await page.waitForSelector('#submit');

await page.locator('#submit').click();

A better approach is:

await page.locator('#submit').click();

How Playwright Auto Waiting Works Submit

Auto-waiting is useful, but it has limits. It waits for the element action to be safe. It does not automatically know whether an API response has finished, whether a client-side route has changed, or whether the next UI state is correct after the action.

Playwright Waits: Quick Decision Table

The easiest way to choose the right Playwright wait is to look at what your test is waiting for. Do not start with the method name. Start with the application signal.

Playwright Waits Quick Decision Table

What you need to wait forRecommended Playwright approachUse when
Element is ready for a click, fill, check, or hoverAuto-waiting through locatorsYou are performing a normal user action
Element becomes visibleawait expect(locator).toBeVisible()You want to verify that the UI shows something
Element disappearsawait expect(locator).toBeHidden()You are waiting for a loader, modal, toast, or message to go away
Element reaches a specific statelocator.waitFor()You need to wait for attached, detached, visible, or hidden state without making an assertion
URL changes after an actionpage.waitForURL()A click, form submit, or redirect changes the route
Specific API response completespage.waitForResponse()The test depends on backend data before checking the UI
Specific API request is sentpage.waitForRequest()You need to confirm that the app triggered the expected network call
Custom browser-side condition becomes truepage.waitForFunction()Readiness depends on JavaScript state, local storage, or app-specific flags
Page reaches a load statepage.waitForLoadState()You need to wait for load or domcontentloaded after navigation
Fixed delaypage.waitForTimeout()Use only for debugging, not normal test logic

The main rule is simple: use the most specific wait that matches the signal. If the test needs the UI to show a result, use an assertion. If it needs a route change, wait for the URL. If it needs an API response, wait for that response. Avoid adding fixed delays just because the test is unstable.

Use Web-First Assertions Before Manual Waits

In Playwright, I would use web-first assertions before adding most manual waits. These assertions keep checking the condition until it passes or the timeout is reached.

For example, instead of waiting for an element and then checking it:

await page.waitForSelector('.success-message');

await expect(page.locator('.success-message')).toBeVisible();

Use the assertion directly:

await expect(page.locator('.success-message')).toBeVisible();

This is cleaner because the assertion already waits for the message to become visible. You are not adding a separate wait that may become redundant later.

Use Web First Assertions Before Manual Waits

Web-first assertions are useful when you want to verify UI state, such as:

  • Element visibility: await expect(locator).toBeVisible();
  • Element hidden state: await expect(locator).toBeHidden();
  • Text content: await expect(locator).toHaveText(‘Order placed’);
  • URL change: await expect(page).toHaveURL(/orders/);
  • Input value: await expect(locator).toHaveValue(‘test@example.com’);
  • Element count: await expect(locator).toHaveCount(3);

This is the rule I follow: if the test is waiting because it needs to verify something, use an assertion. If the test is waiting because it needs a condition before taking the next action, use a specific wait.

For example:

await page.getByRole('button', { name: 'Place Order' }).click();

await expect(page.getByText('Order placed successfully')).toBeVisible();

This reads like a real user flow. The test clicks the button, then waits until the expected result appears. No hard wait is needed.

Place Order

Thank you for your purchase

Types of Playwright Waits with Examples

Playwright gives you different wait options, but they should not be used as interchangeable fixes for flaky tests. Each wait solves a different timing problem.

The sections below explain the main Playwright wait methods with examples, when to use them, and where they can go wrong.

1. locator.waitFor()

locator.waitFor() waits for an element to reach a specific state. This is useful when you need the element state before continuing, but you are not trying to assert the final test result yet.

Common states include:

  • visible: Wait until the element is visible.
  • hidden: Wait until the element is hidden.
  • attached: Wait until the element is present in the DOM.
  • detached: Wait until the element is removed from the DOM.

Example:

const loader = page.locator('.loading-spinner');

await loader.waitFor({ state: 'hidden' });

This waits until the loading spinner is no longer visible before the test continues.

locator.waitFor

locator.waitFor Loaded

You can also use it before interacting with dynamic UI:

const menu = page.getByRole('menu');

await menu.waitFor({ state: 'visible' });

await page.getByRole('menuitem', { name: 'Settings' }).click();

Use locator.waitFor() when the wait is a setup step for the next action. But if the purpose is to verify the UI, prefer an assertion.

Instead of this:

await page.getByText('Payment successful').waitFor({ state: 'visible' });

Use this:

await expect(page.getByText('Payment successful')).toBeVisible();

The difference is intent. locator.waitFor() prepares the test for the next step. expect(locator).toBeVisible() verifies that the expected result happened.

2. expect(locator).toBeVisible()

expect(locator).toBeVisible() is a web-first assertion. It waits until the element becomes visible, then passes the assertion. If the element does not become visible within the timeout, the test fails.

Use this when visibility is the expected result of a user action.

Example:

await page.getByRole('button', { name: 'Save' }).click();

await expect(page.getByText('Profile updated')).toBeVisible();

Here, the test is not just waiting for a message. It is verifying that the save action produced the right UI result.

This is better than writing:

await page.getByRole('button', { name: 'Save' }).click();

await page.waitForSelector('.success-message');

The second example waits for a selector, but it does not clearly explain what the test expects. The assertion is more readable and gives a better failure message when the condition is not met.

You can also use visibility assertions for modals, banners, validation messages, and page sections:

await expect(page.getByRole('dialog', { name: 'Confirm Delete' })).toBeVisible();

await expect(page.getByText('Email is required')).toBeVisible();

await expect(page.getByTestId('dashboard-summary')).toBeVisible();

Use toBeVisible() when the visible state is part of the test result. If you only need the element to be ready before another action, locator.waitFor() may be a better fit.

3. page.waitForURL()

page.waitForURL() waits until the page URL matches the expected value or pattern. Use it when an action causes navigation, a redirect, or a client-side route change.

Example:

await page.getByRole('button', { name: 'Login' }).click();

await page.waitForURL('**/dashboard');

This tells Playwright to continue only after the URL changes to the dashboard route.

You can also use a regular expression when the URL contains dynamic values:

await page.getByRole('link', { name: 'View Order' }).click();

await page.waitForURL(/\/orders\/\d+/);

This works well when the order ID changes every time the test runs.

A common mistake is to use page.waitForNavigation() for this. In modern Playwright tests, prefer page.waitForURL() because it is clearer and less risky. It ties the wait to the route you actually expect, instead of waiting for a broad navigation event.

When the URL change is also your test expectation, you can use a web-first assertion instead:

await page.getByRole('button', { name: 'Login' }).click();

await expect(page).toHaveURL(/dashboard/);

Use page.waitForURL() when the URL change is needed before the next step. Use expect(page).toHaveURL() when the URL itself is what you want to verify.

page.waitForURL Login

page.waitForURL homepage

4. page.waitForResponse()

page.waitForResponse() waits until Playwright receives a matching network response. Use it when the next test step depends on data coming back from a specific API call.

A common example is placing an order and waiting for the order API to complete before checking the UI:

const orderResponse = page.waitForResponse(response =>

 response.url().includes('/api/orders') &&

 response.status() === 200

);


await page.getByRole('button', { name: 'Place Order' }).click();


await orderResponse;

await expect(page.getByText('Order placed successfully')).toBeVisible();

Notice the order here. The wait is created before the click. This prevents the test from missing a fast response that returns immediately after the action.

You can also inspect the response body when the test needs API data:

const responsePromise = page.waitForResponse(response =>

 response.url().includes('/api/orders') &&

 response.status() === 201

);


await page.getByRole('button', { name: 'Place Order' }).click();


const response = await responsePromise;

const responseBody = await response.json();


expect(responseBody.status).toBe('confirmed');

Use page.waitForResponse() when the API response is a meaningful readiness signal. Do not use it for every backend call. If the UI already shows the expected result, a web-first assertion is usually enough.

page.waitForResponse

5. page.waitForRequest()

page.waitForRequest() waits until the browser sends a matching network request. Use it when you need to confirm that the application triggered the correct API call.

For example, after clicking a search button, you may want to confirm that the app sent a request with the right query:

const searchRequest = page.waitForRequest(request =>

 request.url().includes('/api/search') &&

 request.url().includes('q=playwright')

);


await page.getByRole('button', { name: 'Search' }).click();


const request = await searchRequest;

expect(request.method()).toBe('GET');

This is useful when the test is about request behavior, analytics events, form submissions, or API calls that may not produce an immediate visible UI change.

page.waitForRequest Search Request GET

page.waitForRequest Timing

For POST requests, you can also inspect the request payload:

const submitRequest = page.waitForRequest(request =>

 request.url().includes('/api/contact') &&

 request.method() === 'POST'

);


await page.getByRole('button', { name: 'Submit' }).click();


const request = await submitRequest;

const payload = request.postDataJSON();


expect(payload.email).toBe('user@example.com');

Use page.waitForRequest() when sending the request is the thing you need to validate. If your test depends on the server result, use page.waitForResponse() instead.

page.waitForRequest POST

page.waitForRequest Payload

6. page.waitForFunction()

page.waitForFunction() waits until a JavaScript function inside the browser returns a truthy value. Use it when the readiness signal exists in the page itself and cannot be captured cleanly through a locator, URL, request, or response.

For example, some applications expose a browser-side flag when the app is ready:

await page.waitForFunction(() => window.appReady === true);

You can also use it to wait for a value in localStorage:

await page.waitForFunction(() => {

 return localStorage.getItem('checkout_status') === 'ready';

});

This is useful for app-specific readiness checks, client-side state changes, feature flags, or custom events stored on the window object.

Use it carefully. If the condition can be checked through the UI, prefer a web-first assertion:

await expect(page.getByText('Checkout ready')).toBeVisible();

That is easier to read and closer to what the user actually sees.

7. page.waitForLoadState()

page.waitForLoadState() waits until the page reaches a specific loading state. The common states are domcontentloaded, load, and networkidle.

Example:

await page.goto('https://example.com');

await page.waitForLoadState('domcontentloaded');

domcontentloaded

Use domcontentloaded when you need the initial HTML to be parsed before continuing. Use load when you need the page and its dependent resources, such as stylesheets and images, to finish loading.

await page.goto('https://example.com/reports');

await page.waitForLoadState('load');

load Headers

load Timing

Be careful with networkidle. It waits until there are no network connections for a short period, but many modern apps keep sending background requests for analytics, polling, chat widgets, or live updates. This can make networkidle slow or unreliable.

Instead of this:

await page.waitForLoadState('networkidle');

Prefer waiting for the actual UI or API signal:

await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();

Use page.waitForLoadState() when page loading itself is the signal. For most user flows, a locator assertion or URL wait is usually more precise. Use networkidle only when you are sure background requests will not keep the page active.

8. page.waitForTimeout()

page.waitForTimeout() pauses the test for a fixed amount of time. For example, this waits for three seconds:

await page.waitForTimeout(3000);

This is also called a hard wait. It can help while debugging because it gives you time to inspect the page state. But it should not stay in committed test code because it does not check whether the application is actually ready.

page.waitForTimeout Before

page.waitForTimeout After

Avoid this:

await page.getByRole('button', { name: 'Submit' }).click();

await page.waitForTimeout(3000);

await expect(page.getByText('Submitted successfully')).toBeVisible();

Use a real application signal instead:

await page.getByRole('button', { name: 'Submit' }).click();

await expect(page.getByText('Submitted successfully')).toBeVisible();

The second approach is more reliable because Playwright waits until the success message appears or the assertion times out. The test moves forward based on application state, not a guessed delay.

Waits to Avoid or Use Carefully

Not every Playwright wait should be part of regular test logic. Some waits are useful in specific cases, but risky when used as a general fix for flaky tests.

Here are the waits I would avoid or use carefully:

WaitWhy to be carefulBetter option
page.waitForTimeout()Adds a fixed delay without checking app state. Makes tests slower and still fails if the app takes longer.Use web-first assertions, locator.waitFor(), waitForURL(), or network waits.
page.waitForSelector()Older style of waiting. It separates waiting from locator-based actions and assertions.Use locator.waitFor() or expect(locator).toBeVisible().
page.waitForNavigation()Can be broad and risky in modern apps with client-side routing.Use page.waitForURL() or expect(page).toHaveURL().
page.waitForLoadState(‘networkidle’)Can be unreliable in apps with polling, analytics, live updates, or background requests.Wait for a specific UI state, URL, request, or response.
Broad waitForResponse()May match the wrong response if the app sends similar API calls.Match URL, status, method, and sometimes request payload.

A good Playwright wait should be connected to a visible UI change, route change, network event, or browser-side condition.

Common Flaky Wait Problems and Fixes

Flaky waits usually happen when the test waits for an incomplete signal. The page may still be rendering, the API response may not be reflected in the UI yet, or the route may change before the new view is ready.

Here are common wait-related problems I see in Playwright tests and how to fix them.

1. Waiting for the loader instead of the final result

A common mistake is to wait only for the loader to disappear:

await page.getByRole('button', { name: 'Load Report' }).click();

await page.locator('.spinner').waitFor({ state: 'hidden' });

This may still be flaky if the spinner disappears before the report content is fully rendered.

A better approach is to wait for the result the user actually needs:

await page.getByRole('button', { name: 'Load Report' }).click();

await expect(page.getByRole('heading', { name: 'Monthly Report' })).toBeVisible();

await expect(page.getByTestId('report-table')).toBeVisible();

The test should not only wait for “loading is done.” It should wait for the screen to reach the expected state.

2. Using a hard wait to fix CI failures

This is one of the most common fixes, but it usually hides the real issue:

await page.getByRole('button', { name: 'Submit' }).click();


await page.waitForTimeout(5000);


await expect(page.getByText('Submitted successfully')).toBeVisible();

This may reduce failures for a while, but it makes the test slower and still fails when the app takes more than five seconds.

Use the expected UI state instead:

await page.getByRole('button', { name: 'Submit' }).click();


await expect(page.getByText('Submitted successfully')).toBeVisible();

This lets Playwright wait only as long as needed, up to the configured timeout.

3. Waiting for a response but not the UI update

Sometimes the test waits for the API response and then immediately checks the UI:

const responsePromise = page.waitForResponse(response =>

 response.url().includes('/api/orders') &&

 response.status() === 200

);

await page.getByRole('button', { name: 'Place Order' }).click();

await responsePromise;


await expect(page.getByText('Order placed successfully')).toBeVisible();

This is better than a hard wait, but the response alone may not be enough. The frontend still needs to process the response and render the message.

The safer pattern is to wait for the response when it matters, then verify the final UI state:

const responsePromise = page.waitForResponse(response =>

 response.url().includes('/api/orders') &&

 response.status() === 200

);


await page.getByRole('button', { name: 'Place Order' }).click();


const response = await responsePromise;

expect(response.ok()).toBeTruthy();


await expect(page.getByText('Order placed successfully')).toBeVisible();

Network waits are useful, but the user-facing result should still be checked through the UI.

4. Waiting only for the URL in client-side apps

In single-page applications, the URL can change before the new page content is ready:

await page.getByRole('link', { name: 'Dashboard' }).click();

await page.waitForURL('**/dashboard');

This confirms the route changed, but it does not prove the dashboard is ready.

Use the URL wait with a page-level assertion:

await page.getByRole('link', { name: 'Dashboard' }).click();


await page.waitForURL('**/dashboard');

await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

This gives the test two clear signals: the route changed, and the expected page content appeared.

5. Matching the wrong network response

A broad response wait can pass for the wrong API call:

await page.waitForResponse(response =>

 response.url().includes('/api/user')

);

If the page sends multiple user-related requests, this may match the wrong one.

Make the wait more specific:

await page.waitForResponse(response =>

 response.url().includes('/api/user/profile') &&

 response.status() === 200 &&

 response.request().method() === 'GET'

);

For critical flows, match the URL, method, status code, and sometimes the request payload. The wait should describe the exact network event the test depends on.

Best Practices for Playwright Waits

Good Playwright waits are specific. Before adding one, ask whether the test needs a UI change, URL change, network event, or browser-side condition.

Here are the practices I follow when writing or reviewing Playwright waits:

  • Rely on auto-waiting for normal actions: Do not add waitForSelector() before every click, fill, or check. Playwright already waits for elements to become actionable.
  • Use web-first assertions for UI results: If the test expects a message, heading, modal, or table to appear, use assertions like toBeVisible(), toHaveText(), or toHaveURL().
  • Create network waits before the action: If a click triggers an API call, define waitForResponse() or waitForRequest() before the click so the test does not miss a fast request.
  • Keep waits specific: Avoid broad URL or API matching. Match the route, method, status code, and response that the test actually depends on.
  • Avoid hard waits in committed code: page.waitForTimeout() is fine while debugging, but it should be replaced with a real signal before the test is added to the suite.
  • Wait for the final state: Do not stop at intermediate signals like “spinner disappeared” or “URL changed.” Also check that the expected page content or user-facing result is ready.

Conclusion

Playwright waits are most effective when they are tied to a clear application signal. In most cases, you should start with auto-waiting and web-first assertions before adding manual waits. If the test needs a specific route, API response, request, load state, or browser-side condition, use the wait that matches that signal.

Avoid using hard waits as a quick fix for flaky tests. They make the suite slower and still do not guarantee stability. A better Playwright wait strategy is simple: wait for what the user or application actually needs, keep the condition specific, and verify the final state before moving to the next step.

Tags
Automation Testing Playwright
50% of flaky tests are caused by poor waits
Run tests on real browsers with videos, logs, and screenshots.