ScreenshotNeo

BlogHow-to

How to Check Whether an Element Exists in Playwright

Learn when to use toBeAttached, toBeVisible, and toHaveCount in Playwright, with reliable locators, timing fixes, code, and troubleshooting.

By the ScreenshotNeo team29 September 20269 min read

How to Check Whether an Element Exists in Playwright

When a developer asks whether an element “exists” in Playwright, that can mean three different things: the node is connected to the DOM, the node is visible to a user, or a locator matches a particular number of nodes. Choose the assertion that matches the question.

What you mean Use What it proves
Connected to the DOM or a shadow root await expect(locator).toBeAttached() The locator resolves to an attached node
Visible to the user await expect(locator).toBeVisible() The node is attached and meets Playwright’s visibility rules
Exactly a certain number of matches await expect(locator).toHaveCount(n) The locator matches exactly n nodes
Read the current state for branching await locator.isVisible() or await locator.count() An immediate snapshot, without waiting for a later state

These are web-first assertions. They retry until the expected condition is true or the assertion timeout expires, which is usually safer than reading a page while it is still rendering. See the official LocatorAssertions API and auto-waiting documentation.

1. Decide what “exists” means

Attached to the DOM

Use toBeAttached() when you need to know that a node is connected to a Document or ShadowRoot. An attached node can still be hidden with CSS, outside the viewport, empty, or covered by another element.

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

test('the status node exists in the DOM', async ({ page }) => {
  await page.goto('https://example.com/account');
  const status = page.getByRole('status');
  await expect(status).toBeAttached();
});

This is the direct answer when “exists” means “the application rendered the node.”

Visible to the user

Use toBeVisible() when the user must be able to see the element. Playwright considers an element visible when it is attached, has a non-empty bounding box, and its computed visibility is not hidden. An element with display: none, zero dimensions, or a hidden ancestor fails this assertion.

test('the save button is visible', async ({ page }) => {
  await page.goto('https://example.com/editor');
  await expect(
    page.getByRole('button', { name: 'Save' })
  ).toBeVisible();
});

Exactly N matching nodes

Use toHaveCount(n) when cardinality matters. This catches duplicate buttons, repeated rows, and missing list items.

test('there is one primary navigation', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page.getByRole('navigation', { name: 'Primary' })).toHaveCount(1);
});

For “at least one,” do not claim that one exact match is enough unless duplicates are a defect. You can assert the first match is attached:

await expect(page.getByTestId('toast').first()).toBeAttached();

When duplicate matches should be impossible, keep the stronger exact-count assertion so a regression is visible.

2. Build a locator that identifies the right element

The quality of the existence check depends on the locator. Playwright recommends user-facing locators and explicit contracts. Prefer roles and accessible names for interactive controls, labels for form fields, and test IDs when you own a stable testing contract. The Locators guide explains the available strategies.

const save = page.getByRole('button', { name: 'Save' });
const email = page.getByLabel('Email address');
const result = page.getByRole('region', { name: 'Search results' });
const card = page.getByTestId('product-card');

Avoid broad CSS selectors such as div.button when a page has several similar components. If an operation requires one target and the locator matches many nodes, Playwright’s strictness will report the ambiguity. Narrow the locator with a role name, a parent relationship, or a deliberate first(), last(), or nth().

const dialog = page.getByRole('dialog', { name: 'Delete project' });
await expect(dialog).toBeAttached();
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();

Locators resolve the current DOM element when used. That matters in React, Vue, and other applications that replace nodes during re-rendering.

3. Waiting versus an immediate snapshot

isVisible() and count() return the state at the moment they run. They do not wait for a loading spinner to disappear or for an API response to render a component. Use them when you intentionally need a conditional branch based on the current state.

Web-first assertions retry while the page reaches the expected state.
Web-first assertions retry while the page reaches the expected state.
const banner = page.getByRole('alert');
if (await banner.isVisible()) {
  await banner.getByRole('button', { name: 'Dismiss' }).click();
}

const rows = page.getByRole('row');
const rowCountNow = await rows.count();
console.log(`Rows currently in the DOM: ${rowCountNow}`);

For an asynchronous expectation, use a web-first assertion instead:

await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('row')).toHaveCount(10);

This distinction prevents a common race: the test checks immediately, receives false or 0, and fails even though the application would render the element a few milliseconds later.

4. Complete runnable TypeScript example

Install Playwright Test, create a test file, and run it with the project’s configured browser.

npm init playwright@latest
npx playwright test

The following test demonstrates attachment, visibility, exact count, conditional branching, and a delayed UI update.

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

test('checks the different meanings of exists', async ({ page }) => {
  await page.goto('https://example.com/dashboard');

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

  const navigation = page.getByRole('navigation');
  await expect(navigation).toHaveCount(1);

  const optionalNotice = page.getByTestId('maintenance-notice');
  if (await optionalNotice.count() > 0) {
    console.log('A maintenance notice is currently present');
  }

  const results = page.getByRole('listitem');
  await expect(results).toHaveCount(10, { timeout: 15_000 });
});

Set a larger timeout only for a known slow operation. A long timeout applied everywhere can hide a real performance regression.

5. Configuration and timeout control

Playwright has separate timeouts for actions, navigation, and assertions. The assertion timeout controls how long toBeAttached(), toBeVisible(), and toHaveCount() retry.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: { timeout: 5_000 },
  use: {
    actionTimeout: 10_000,
    navigationTimeout: 30_000,
    trace: 'retain-on-failure'
  }
});

Override a single assertion when a specific component has a documented delay:

await expect(page.getByRole('status')).toBeVisible({ timeout: 20_000 });

Use a short timeout for a deliberately optional element, then branch on the result only when that behavior is intentional. Do not replace a retrying assertion with arbitrary sleeps. A fixed waitForTimeout makes tests slower and still leaves a race when the application takes longer than the chosen delay.

6. Dynamic, hidden, and removed elements

Elements that appear later

Wait for the state you need:

await page.getByRole('button', { name: 'Load more' }).click();
await expect(page.getByRole('listitem')).toHaveCount(20);

Hidden but attached nodes

A modal may remain in the DOM while its CSS hides it. Use toBeAttached() to verify the component exists and toBeVisible() to verify the open state. These are intentionally different checks.

const modal = page.getByRole('dialog', { name: 'Settings' });
await expect(modal).toBeAttached();
await expect(modal).toBeVisible();

Nodes removed after an action

Use the negated visibility or attachment assertion when disappearance is the requirement:

await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
// If the application removes it entirely:
await expect(page.getByRole('dialog')).not.toBeAttached();

Multiple matches

Inspect the count before choosing a match:

const notices = page.getByRole('alert');
await expect(notices).toHaveCount(2);
await expect(notices.nth(1)).toBeVisible();

Use nth() only when ordering is part of the contract. Otherwise, add a name or scope the locator to a meaningful container.

7. Shadow DOM, frames, and special cases

Shadow DOM

toBeAttached() also accepts nodes connected to a shadow root. Use a locator that crosses the component boundary when supported by the component’s markup.

const search = page.locator('user-search').getByRole('textbox');
await expect(search).toBeAttached();

iframes

An iframe has a separate document. Create a frame locator instead of searching the top-level page.

const checkout = page.frameLocator('iframe[title="Checkout"]');
await expect(checkout.getByRole('button', { name: 'Pay' })).toBeVisible();

Virtualized lists

Virtualized components may render only the rows near the viewport. A total count assertion can fail even when the logical dataset contains more records. Scroll or query the application’s own pagination contract, then assert the rendered rows that should be present.

Canvas and non-DOM content

Pixels drawn on a canvas are not separate DOM elements. Test the canvas element’s attachment or accessibility contract, and use screenshot comparisons for the rendered pixels when visual output is the requirement.

8. Troubleshooting common failures

Failure Likely cause Fix
toBeVisible times out The node is hidden, has zero size, or never rendered Check attachment separately, inspect computed styles, and wait for the real application signal
toBeAttached times out The locator is wrong or the component was not created Use Playwright Inspector or trace output; verify role, name, frame, and route
Strict mode violation The locator matches multiple nodes Narrow it, assert the expected count, or deliberately select a match
Count is zero intermittently count() was read before rendering finished Use toHaveCount() or assert a reliable loading transition
Works locally, fails in CI Different timing, viewport, browser, or seeded data Use stable user-facing locators, explicit test data, traces, and a suitable assertion timeout
Element is in an iframe The search ran in the top-level document Use frameLocator() and locate inside the frame
Element is “visible” but click fails Another element intercepts the pointer or the target is moving Wait for the correct state, inspect actionability, and fix the UI or locator rather than forcing the click

When debugging, run with a trace and inspect the DOM snapshot at the failed step. The trace often shows whether the locator matched nothing, matched several nodes, or found a hidden element.

9. Performance and reliability guidance

  • Prefer one precise locator over repeated page-wide CSS queries.
  • Use role, label, and test ID locators that describe the intended contract.
  • Keep assertion timeouts close to the application’s expected response time.
  • Use toHaveCount() for a known exact cardinality; avoid repeatedly calling count() in polling loops.
  • Do not use sleeps to solve rendering races.
  • Keep test data deterministic so a count assertion describes a known fixture.
  • For expensive pages, wait for a meaningful readiness signal instead of waiting for every network request to finish.

10. Or skip the browser setup

If your goal is to capture a page after checking its rendered state, ScreenshotNeo provides a website screenshot API and MCP server. It accepts one GET request and returns PNG, JPEG, WebP, or PDF. The API can wait for a selector, delay, or network idle, capture a full page or one CSS-selected element, set a viewport or device preset, run custom JavaScript, and hide selectors. Before capture it accepts consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be turned off.

See the ScreenshotNeo API documentation for all options. A minimal request is:

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}`);

Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers include X-Page-Verdict and X-Billed, so your application can distinguish a clean billed capture from a failed or non-billed result. ScreenshotNeo also supports custom headers, cookies, user agents, authorization, geolocation, timezone, transparent backgrounds, image resizing, caching with a chosen TTL, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an OpenAPI specification. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

There is a free allowance of 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account and start with the included 1,000 screenshots.

11. Short FAQ

Is toBeAttached() the same as visible?

No. It only proves the node is connected to the DOM or a shadow root. Use toBeVisible() for user-visible state.

Can I use locator.count() in an assertion?

You can read it, but it is an immediate value. Prefer toHaveCount() when the page may update.

How do I check that an element does not exist?

Use await expect(locator).not.toBeAttached() when it must be absent from the DOM, or not.toBeVisible() when it may remain attached but must be hidden.

What should I assert for a button?

Use a role locator with its accessible name, then choose attachment, visibility, count, or enabled state according to the behavior under test.

Why does a locator match two elements?

The selector describes both nodes. Add a role name, scope it to a container, assert the intended count, or choose a positional match only when order is guaranteed.