ScreenshotNeo

BlogHow-to

Wait for an Element in Playwright

Learn the reliable ways to wait for elements in Playwright: locators, assertions, states, timeouts, frames, debugging, and auto-waiting.

By the ScreenshotNeo team1 October 20268 min read

Use a Locator with a retrying assertion when you want to verify that an element eventually reaches a state:

import { test, expect } from '@playwright/test';

test('shows the confirmation', async ({ page }) => {
  const confirmation = page.getByRole('status');
  await expect(confirmation).toBeVisible();
});

expect(locator).toBeVisible() retries until the condition passes or the assertion timeout expires. Use locator.waitFor() when you need an explicit setup wait, and rely on Playwright’s action auto-waiting when the next operation is an interaction such as click(). Locators are the central part of Playwright’s auto-waiting and retry behavior. Playwright locators documentation

1. Choose the wait that matches the condition

What must be true? Recommended API Example
The user-visible result exists Web-first assertion await expect(locator).toBeVisible()
The element has specific content Web-first assertion await expect(locator).toHaveText('Saved')
The element is present in the DOM Explicit locator wait await locator.waitFor({ state: 'attached' })
The element is shown Explicit locator wait await locator.waitFor({ state: 'visible' })
The element disappears Assertion or explicit wait await expect(locator).toBeHidden()
The next action can proceed Action auto-wait await locator.click()

Do not choose a timeout first and then hope the page is ready. Describe the state your test actually requires.

2. Wait with a web-first assertion

Assertions are the best default in Playwright Test because they both wait and verify the outcome.

import { test, expect } from '@playwright/test';

test('search results appear', async ({ page }) => {
  await page.getByRole('textbox', { name: 'Search' }).fill('playwright');
  await page.getByRole('button', { name: 'Search' }).click();

  const results = page.getByTestId('search-results');
  await expect(results).toBeVisible();
  await expect(results).toHaveCount(1);
  await expect(results).toContainText('playwright');
});

Useful retrying assertions include toBeVisible(), toBeAttached(), toBeHidden(), toHaveText(), toContainText(), toHaveCount(), toHaveAttribute(), and toHaveValue(). Pick the assertion that expresses the behavior under test.

3. Wait explicitly with locator.waitFor()

const result = page.getByTestId('search-results');
await result.waitFor({ state: 'visible' });

locator.waitFor() resolves immediately when the locator already satisfies the requested state. Its supported states are:

State Meaning
attached The element exists in the DOM, whether or not it is visible.
detached The element is no longer in the DOM.
visible The element has a non-empty bounding box and is not visibility:hidden.
hidden The element is detached, has an empty bounding box, or has visibility:hidden.

The default state is visible. Use attached when DOM presence is sufficient, such as waiting for a script-created node before reading its attributes. Use visible when the test requires the element to be displayed.

4. Let actions auto-wait

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

For actions such as click(), Playwright waits for the locator to resolve to one element and for actionability checks to pass. A click checks visibility, stability, whether the element receives pointer events, and whether it is enabled. Add a separate wait only when there is a distinct condition to establish before the action.

// Usually enough: click waits for the button to be ready.
await page.getByRole('button', { name: 'Save' }).click();

// Add a separate condition when it is part of the behavior being tested.
await expect(page.getByRole('status')).toHaveText('Ready');
await page.getByRole('button', { name: 'Save' }).click();

5. Pick a locator that survives re-rendering

Prefer user-facing locators and stable test contracts:

page.getByRole('button', { name: 'Save' });
page.getByLabel('Email');
page.getByPlaceholder('Search');
page.getByText('Order complete');
page.getByAltText('Product image');
page.getByTitle('Help');
page.getByTestId('order-status');

Locators describe how to find an element; they are resolved again when used. This helps when a framework re-renders the page. If an operation requires one element and your locator matches several, narrow it or make the intended match explicit.

const rows = page.getByRole('row');
await expect(rows).toHaveCount(4);
await rows.filter({ hasText: 'Ada Lovelace' }).getByRole('button', { name: 'Edit' }).click();

Avoid brittle selectors based on generated CSS classes or deep DOM paths unless they are the only stable contract.

6. Handle frames, lists, and dynamic content

Elements inside an iframe

const paymentFrame = page.frameLocator('iframe[title="Payment"]');
await expect(paymentFrame.getByLabel('Card number')).toBeVisible();
await paymentFrame.getByLabel('Card number').fill('4242424242424242');

Scope through a frame locator before locating the element. A page locator cannot directly match nodes inside a separate frame document.

Multiple matches

const notifications = page.getByRole('alert');
await expect(notifications).toHaveCount(1);
await expect(notifications.first()).toBeVisible();

Use first(), last(), or nth() only when that ordering is intentional. Prefer a role, accessible name, test id, or filter that identifies the correct element.

Wait for the meaningful value

const total = page.getByTestId('cart-total');
await expect(total).toHaveText('$42.00');

Visibility alone can pass while the component still shows a loading placeholder. Assert the text, count, attribute, or value that proves the page is ready for the next test step.

7. Visibility is not the same as interactability

Playwright defines visibility using a non-empty bounding box and the absence of visibility:hidden. An element with opacity: 0 can therefore satisfy a visibility check. A click has additional actionability checks, including whether another element intercepts pointer events. If the user must be able to interact with the control, test the action itself or use a more specific assertion.

const submit = page.getByRole('button', { name: 'Submit' });
await expect(submit).toBeVisible();
await expect(submit).toBeEnabled();
await submit.click();

8. Timeouts and configuration

A wait that does not reach its requested state within its effective timeout throws a TimeoutError. Locator waits and web-first assertions use different timeout configuration paths. The locator API reference documents a default timeout of zero, while Playwright Test assertions use a configured expect timeout that commonly defaults to five seconds. Check the Playwright version and project configuration instead of assuming one universal value.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  timeout: 30_000,
  expect: { timeout: 5_000 },
  use: { actionTimeout: 10_000 }
});

Override a timeout for a genuinely slow condition:

await expect(page.getByTestId('report')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('report').waitFor({ state: 'visible', timeout: 30_000 });

Do not increase a timeout before checking the locator, frame, page URL, and actual DOM state. A long timeout can hide a broken selector or a page that never completed its request.

9. Patterns to avoid

Fixed sleeps

// Avoid as a general element-wait strategy.
await page.waitForTimeout(3000);

A sleep waits for elapsed time rather than the element’s state. It wastes time on fast runs and can still be too short on slow runs. Replace it with a locator assertion or explicit state wait.

Immediate visibility checks

// This returns immediately; it does not retry.
const visible = await page.getByTestId('result').isVisible();

isVisible() is useful for an immediate branch, but it is not an eventual wait. Use await expect(locator).toBeVisible() or await locator.waitFor({ state: 'visible' }) when the element may appear later.

Legacy selector waits

// Available, but discouraged for new code.
await page.waitForSelector('[data-testid="result"]');

The Page API marks page.waitForSelector() as discouraged in favor of locator-based waits and assertions. Existing suites can migrate one selector at a time.

10. A complete runnable example

import { test, expect } from '@playwright/test';

test('waits for a product to load and adds it to the cart', async ({ page }) => {
  await page.goto('https://example.com/products/42');

  const product = page.getByRole('main');
  await expect(product).toBeVisible();
  await expect(product.getByRole('heading', { name: /product/i })).toBeVisible();

  const price = product.getByTestId('price');
  await expect(price).toHaveText(/\$\d+/);

  const addButton = product.getByRole('button', { name: 'Add to cart' });
  await expect(addButton).toBeEnabled();
  await addButton.click();

  await expect(page.getByRole('status')).toHaveText('Added to cart');
});

Replace the example URL and locators with the contracts in your application. The test waits for the result it needs at each stage instead of guessing how long the page will take.

11. Troubleshooting timeouts

Symptom Likely cause Fix
toBeVisible() times out The locator is wrong, the element is hidden, or the page is still loading. Inspect the locator, URL, DOM, and computed state. Decide whether you need attached, visible, text, or count.
Strict mode violation The locator matches multiple elements. Use a stronger role/name, filter by text, or assert the count before selecting one.
Element is visible but click fails An overlay intercepts events, the element moves, or it is disabled. Wait for the overlay to be hidden, assert enabled state, and inspect actionability details.
Element is never found in the page It is inside an iframe. Use frameLocator() and then locate the element within that frame.
Text assertion passes too early The assertion targets a placeholder or the wrong matching node. Target the component’s stable locator and assert its final text or value.
Works locally, fails in CI Different timing, viewport, authentication, network, or browser state. Use state-based waits, capture a trace, and verify the same context and setup in CI.
Increasing timeout does not help The page never reaches the requested state or the selector is invalid. Debug the condition first; only then choose a timeout appropriate for the real operation.

For failures, inspect the trace, screenshot, console, network, current URL, and locator count. These reveal whether the problem is timing, navigation, a frame boundary, or an incorrect assumption about the page.

12. Performance, reliability, and cost notes

  • Assertions and locator waits finish as soon as the condition is met, so they avoid the unnecessary delay of fixed sleeps.
  • Stable, user-facing locators reduce maintenance when markup is re-rendered.
  • Waiting for a narrower condition, such as final text or a specific count, is usually more reliable than waiting for a broad container.
  • Keep default timeouts conservative and raise them only for known slow operations.
  • Playwright itself has no per-wait charge; the practical costs are test runtime and the infrastructure running your browser workers.
  • Do not use a screenshot or network-idle delay as a substitute for the application state your test actually needs.

13. Or skip the browser setup

If your goal is to capture a page after it has loaded, ScreenshotNeo provides a website screenshot API and MCP server. It handles the browser setup for a single GET request and returns PNG, JPEG, WebP, or PDF.

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response reports the page verdict and billing status in X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

There are 1,000 free shots per month with no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

14. FAQ

Should I use toBeVisible() or waitFor({ state: 'visible' })?

Use toBeVisible() when visibility is part of the behavior you are verifying. Use waitFor() when visibility is a setup precondition and you do not need an assertion message about the outcome.

Does Playwright wait for network idle before finding an element?

No. Element waits observe the locator state. Choose a page condition that proves readiness instead of assuming all network activity has ended.

Can an invisible element satisfy toBeVisible()?

An element with zero-size layout or visibility:hidden does not. An element with opacity:0 can satisfy Playwright’s documented visibility definition, so use an actionability check when interaction matters.

When should I wait for attached?

Use it when DOM presence is enough, such as reading an attribute from a node that is intentionally not visible. Most user-facing tests need visibility or a more specific assertion.