Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use await locator.waitFor({ state: 'visible' }) when a test must explicitly wait for a locator state. If the point of the test is to verify visibility, prefer the retrying assertion await expect(locator).toBeVisible(). Normal Playwright actions such as click() already wait for their own actionability requirements, so add an explicit wait only when it represents a separate condition your test needs.

The three ways to wait in Playwright

Playwright offers three distinct mechanisms. Choosing by intent keeps tests readable and avoids unnecessary delays.

Intent Recommended API What happens
Perform an action when the target is ready await locator.click(), fill(), and similar actions Playwright waits for the actionability checks required by that action, including visibility, stability, event reception, and enabled state where applicable.
Synchronize on a DOM state without asserting it await locator.waitFor({ state: 'visible' }) Waits until the locator is attached, detached, visible, or hidden. The default state is visible.
Verify that an eventual condition is true await expect(locator).toBeVisible() Retries the assertion until it passes or the applicable timeout expires, producing an assertion failure when the condition is not met.

The distinction matters. A synchronization wait says “do not continue until this state exists.” A web-first assertion says “this state is part of the behavior I am testing.” The Locator API specifically recommends expect(locator).toBeVisible() when visibility itself must be asserted.

Build a reliable locator first

Waiting is only as reliable as the locator being waited on. Prefer user-facing locators such as getByRole, getByLabel, and getByText, then narrow them until they identify one intended target. For example:

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const savedMessage = page.getByRole('status').filter({ hasText: 'Saved' });

Playwright locators are re-resolved against the current DOM each time they are used, which helps when a framework re-renders an element. Operations that imply one target are strict: if the locator matches multiple elements, Playwright fails instead of silently choosing one. The locators guide documents these recommendations and strictness rules.

Do not cache an ElementHandle merely to avoid resolving a locator again. A locator is designed to survive ordinary DOM replacement; an old handle can point at a node that the application has already discarded.

Wait explicitly with locator.waitFor()

Call waitFor() on a locator when the test needs a state transition but is not making that transition the assertion under test:

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

test('shows a save status before reading it', async ({ page }) => {
  await page.goto('https://example.com/settings');

  const status = page.getByRole('status');
  await status.waitFor({ state: 'visible' });
  await expect(status).toHaveText('Saved');
});

waitFor() accepts four states. The documented default is visible; its timeout defaults to zero, which means Playwright uses the configured timeout defaults rather than an arbitrary built-in delay.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

visible

The locator must resolve to an element with a non-empty bounding box that is not styled with visibility: hidden. This is the usual choice for a panel, status message, or control that must appear before the next operation.

attached

The element must be present in the DOM. Attachment does not mean a user can see or interact with it. Use this for DOM-driven transitions where rendering or interactivity is checked separately.

await page.getByTestId('results').waitFor({ state: 'attached' });

detached

The matching element must be removed from the DOM. This is useful for waiting until a temporary modal or loading node is torn down.

await page.getByRole('dialog', { name: 'Loading' }).waitFor({ state: 'detached' });

hidden

The locator must be detached or not visible according to Playwright’s visibility rules. Choose this when either removal or visual hiding satisfies the application’s completion condition.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await page.getByText('Saving…').waitFor({ state: 'hidden' });

Set a timeout only for this wait when necessary

You can override the applicable timeout for one wait:

await page.getByRole('status').waitFor({
  state: 'visible',
  timeout: 15_000
});

A timeout failure means the requested state was not reached within that limit. Increasing the number can be appropriate for a known slow operation, but it should not mask a wrong locator or an application that never reaches the expected state.

Use expect(locator).toBeVisible() for visibility assertions

When visibility is the behavior under test, make it explicit with a web-first assertion:

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

test('confirms the success message', async ({ page }) => {
  await page.goto('https://example.com/checkout');
  await page.getByRole('button', { name: 'Pay' }).click();

  await expect(page.getByRole('status')).toBeVisible();
  await expect(page.getByRole('status')).toHaveText('Payment complete');
});

The assertion retries while the page changes, then fails as an assertion if the condition never becomes true. This is preferable to taking a one-time visibility snapshot immediately after an action.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Other web-first assertions follow the same model. For example, toBeHidden() verifies that a loading indicator eventually disappears, while toHaveText() verifies the eventual text and includes the retrying behavior.

Understand what actions already wait for

Adding waitFor({ state: 'visible' }) before every click is usually redundant. According to Playwright’s auto-waiting and actionability documentation, an action waits for the checks relevant to that action. A click, for example, waits for the element to be visible, stable, able to receive pointer events, and enabled.

const submit = page.getByRole('button', { name: 'Submit' });
await submit.click(); // waits for click actionability automatically

A separate wait is justified when it communicates another condition, such as waiting for a status element after the click:

await submit.click();
await expect(page.getByRole('status')).toBeVisible();

Visibility alone does not guarantee that an element is enabled, stable, or able to receive pointer events. Let the action perform its own actionability checks rather than treating visibility as a complete readiness test.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

APIs that commonly cause flaky waits

Do not use isVisible() as an eventual wait

await locator.isVisible() returns an immediate boolean. It does not retry while the application renders. Use it only when a snapshot is deliberately what you need; for an eventual condition, use waitFor() or a web-first assertion.

// Snapshot, not a wait:
const currentlyVisible = await page.getByRole('status').isVisible();

// Eventual assertion:
await expect(page.getByRole('status')).toBeVisible();

Avoid arbitrary sleeps

waitForTimeout() waits for elapsed time, not for the condition your test depends on. A fast run wastes time; a slow run still fails. Wait for a locator state, an assertion, or a meaningful application signal instead.

Prefer locator APIs over page.waitForSelector()

page.waitForSelector() remains available, but the Page API reference marks it discouraged and points new code toward locator-based waiting and web-first assertions. Locator APIs preserve the semantic target you chose and work with Playwright’s re-resolution model.

Practical patterns for dynamic pages

Wait for a result after an action

const searchBox = page.getByRole('searchbox', { name: 'Search' });
const results = page.getByRole('list', { name: 'Search results' });

await searchBox.fill('playwright');
await expect(results).toBeVisible();
await expect(results.getByRole('listitem').first()).toContainText('Playwright');

Wait for a loading indicator to finish

const spinner = page.getByRole('progressbar');
await expect(spinner).toBeVisible();
await expect(spinner).toBeHidden();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

This expresses both phases without assuming how long the request takes.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Disambiguate repeated matches

const rows = page.getByRole('row');
await rows.nth(2).waitFor({ state: 'visible' });

Prefer a semantic filter or a unique accessible name over an index when possible. If a locator unexpectedly matches several elements, fix the locator rather than adding a wait.

Keep the locator close to its use

const toast = page.getByRole('status', { name: 'Profile updated' });
await expect(toast).toBeVisible();
await expect(toast).toHaveText('Profile updated');

This makes the condition readable and allows the locator to re-resolve after a render.

Timeouts, diagnostics, and recovery

“Locator resolved to multiple elements”

The locator is not strict enough for the operation. Narrow it with an accessible name, filter({ hasText }), a label, or a container; use nth() only when position is genuinely part of the requirement.

“Timeout exceeded” although the element exists

  • Check that the locator targets the intended frame, dialog, or container.
  • Decide whether the requirement is DOM attachment or visual visibility; change state accordingly.
  • Inspect whether the element is empty-sized or has visibility: hidden; such an element is not visible by Playwright’s definition.
  • Confirm that the application actually reaches the state in this scenario rather than merely increasing the timeout.

The element is visible but click() still fails

Visibility is only one actionability check. An animation may still be moving the element, an overlay may intercept pointer events, or the control may be disabled. Fix the page state or locator; do not replace the action with a fixed sleep.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A check passes locally but fails in CI

Replace timing assumptions with a state assertion, use a resilient user-facing locator, and capture the failure trace or screenshot configured by your test runner. If a backend operation has a known longer upper bound, set a targeted timeout on that assertion or wait rather than globally inflating every test.

The wait never finishes after a re-render

Use a locator rather than an element handle, and make sure the locator is not tied to a transient class or generated identifier. Locators re-resolve against the current DOM, which is why they are safer across ordinary component re-renders.

Performance and maintainability

  • Rely on action auto-waiting instead of inserting a visibility wait before every action.
  • Use assertions for conditions that describe user-visible behavior; they document what a test protects.
  • Use attached only when visual readiness is not required; an attached but hidden node can still be unusable to a user.
  • Set per-step timeouts for exceptional operations and keep normal test timeouts aligned with the application’s expected response time.
  • Keep state transitions observable: a status, heading, row count, or hidden spinner is more deterministic than elapsed time.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your goal is to capture a page rather than test an interaction, ScreenshotNeo provides a website screenshot API and MCP server. It accepts a cookie or consent banner before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers.

One GET request is enough:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp

See the ScreenshotNeo API documentation for the complete parameter set. The same request in Python is:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

And in Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. Its Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Can one locator be reused after a component replaces its DOM node?

Yes. A locator is a description that Playwright resolves when it is used, rather than a permanent reference to one old node. This is why it is appropriate for applications that re-render components.

Should a synchronization wait be visible in the test name?

Name the test after the behavior it verifies. Keep an implementation-only synchronization wait in the body, but express user-observable outcomes with web-first assertions so failures explain what behavior was missing.

What should I change first when a wait is flaky?

Inspect the locator and the expected state before changing timeouts. A wrong match, an attached-but-hidden element, or an overlay commonly explains the failure more directly than a slow test runner.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For an explicit locator state, use locator.waitFor({ state }); for a condition the test must prove, use a retrying expect(locator) assertion; and for ordinary interactions, let Playwright’s actionability auto-wait do its job.

Frequently Asked Questions

Can one locator be reused after a component replaces its DOM node?

Yes. A locator is resolved when used, so it can continue to target the intended element after ordinary component re-renders.

Should a synchronization wait be visible in the test name?

Name the test after the behavior it verifies; keep implementation-only synchronization in the body and express user-observable outcomes with assertions.

What should I change first when a wait is flaky?

Inspect the locator and expected state before increasing timeouts. Wrong matches and attached-but-hidden elements are common causes.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.