ScreenshotNeo

BlogHow-to

How to Wait for an Enabled Element in Playwright

Use Playwright’s retrying toBeEnabled assertion to wait for controls, avoid flaky tests, and understand when click already waits for you.

By the ScreenshotNeo team1 October 20267 min read

To wait until a control becomes enabled in Playwright Test, use the retrying web assertion:

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

test('submits when the button is enabled', async ({ page }) => {
  await page.goto('https://example.com/form');

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

toBeEnabled() keeps checking the locator until it matches or the assertion timeout expires. Always await it. If your test only needs to click the control, await submit.click() already waits for enabled state as part of Playwright’s actionability checks.

Playwright’s official documentation describes these checks in its auto-waiting guide. The assertion API is documented in LocatorAssertions.

1. The direct solution: toBeEnabled()

Use a locator-based assertion when the test needs to establish that a control has become enabled:

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

The assertion retries against the current DOM. This matters when a framework replaces the button during a render or when an asynchronous request removes its disabled state.

Set a longer assertion timeout only when the product behavior genuinely needs it:

test('waits for server-side validation', async ({ page }) => {
  await page.goto('https://example.com/form');

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

Keep the timeout close to the assertion when only one state transition is slow. A project-wide expectation timeout can also be configured in Playwright Test configuration when that matches the test suite’s normal behavior.

2. Choose the API that matches your intent

Approach Waits for eventual enabled state? Use it when
expect(locator).toBeEnabled() Yes You need to verify and synchronize on enabled state.
locator.isEnabled() No You need the boolean state immediately for a branch or observation.
locator.click() Yes, as part of actionability You only need to perform the click when the target is ready.
locator.waitFor() No enabled state You need attached, detached, visible, or hidden state.

isEnabled() is an immediate check

const enabledNow = await page.getByRole('button', { name: 'Submit' }).isEnabled();
if (enabledNow) {
  // Branch on the state observed at this moment.
}

This does not wait for a future transition. Replacing it with a polling loop usually makes a test harder to read and less reliable than using toBeEnabled().

waitFor({ state: 'enabled' }) does not exist

locator.waitFor() documents attached, detached, visible, and hidden. Enabled is a separate assertion state:

await locator.waitFor({ state: 'visible' });
await expect(locator).toBeEnabled();

Usually the explicit visibility wait is unnecessary because a click also checks visibility and other actionability conditions.

Click directly when no separate assertion is needed

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

Before clicking, Playwright waits for a unique target that is visible, stable, able to receive events, and enabled. Add toBeEnabled() when enabled state is itself a requirement or when a separate assertion gives a clearer failure message.

3. Complete TypeScript example

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

test('waits for an enabled submit button', async ({ page }) => {
  await page.goto('https://example.com/checkout');

  const email = page.getByLabel('Email');
  const terms = page.getByRole('checkbox', { name: 'Accept terms' });
  const submit = page.getByRole('button', { name: 'Place order' });

  await email.fill('dev@example.com');
  await terms.check();

  // The assertion retries while validation or application state updates.
  await expect(submit).toBeEnabled();
  await submit.click();

  await expect(page.getByText('Order received')).toBeVisible();
});

Use the locator that represents the user-facing contract. getByRole() with an accessible name is the usual choice for buttons and checkboxes. getByLabel() fits labeled form controls. Other deliberate choices include getByText(), getByPlaceholder(), and getByTestId(). Playwright locators are resolved against the current DOM when used, so they can follow a replacement element after a rerender.

4. What Playwright considers enabled

Playwright treats a control as disabled when native controls such as buttons, selects, inputs, textareas, options, or optgroups have a disabled attribute, when they are inside a disabled fieldset, or when they are descendants of an element with aria-disabled="true".

Enabled does not mean clickable in every situation. A control can be enabled while an overlay intercepts pointer events, while it is moving, while it is hidden, or while the locator matches more than one element. A click checks those additional conditions.

The HTML disabled attribute only has native meaning on native form controls. Browsers ignore it on arbitrary elements such as a div. Custom controls should expose appropriate semantics, including an accessible role and disabled state.

// Native control: the disabled attribute has browser semantics.
<button disabled>Submit</button>

// Custom control: expose the intended semantics explicitly.
<div role="button" aria-disabled="true">Submit</div>

5. Waiting through common UI patterns

Enable after an API response

const submit = page.getByRole('button', { name: 'Submit' });
await page.getByLabel('Username').fill('alice');
await page.waitForResponse(response =>
  response.url().includes('/validate') && response.ok()
);
await expect(submit).toBeEnabled();

Use a response wait only when the response itself is part of the synchronization contract. The enabled assertion remains the final condition your user can act on.

Enable after another control changes

await page.getByRole('checkbox', { name: 'I agree' }).check();
await expect(page.getByRole('button', { name: 'Continue' })).toBeEnabled();

React or Vue rerender replaces the element

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

Keep the locator instead of storing an element handle. The locator is re-resolved during retries and actionability checks.

Wait for a custom readiness condition

For a condition that has no direct assertion, use a locator function wait and let the locator be resolved again on retries:

const editor = page.getByTestId('editor');
await editor.waitForFunction(element =>
  element.getAttribute('data-ready') === 'true'
);

Do not use a custom predicate for ordinary enabled-state waiting; toBeEnabled() states the requirement directly.

6. Troubleshooting

Symptom Cause Fix
toBeEnabled times out The application never removes the disabled state, validation has failed, or the locator targets the wrong element. Inspect the locator, required fields, and the element’s disabled or ARIA-disabled state. Increase the timeout only if the expected transition is legitimately slower.
locator.isEnabled() returns false immediately It is a snapshot, not a wait. Replace it with await expect(locator).toBeEnabled() when waiting is required.
“Unknown state enabled” from waitFor waitFor() has no enabled state. Use toBeEnabled().
Click still times out after the element is enabled The target may be hidden, moving, covered by an overlay, unable to receive events, or matched by multiple elements. Check visibility, stability, overlays, and locator uniqueness. Let the click’s actionability error identify the unmet condition.
The assertion passes for the wrong control A broad text or CSS locator matches several elements. Use a role and accessible name, label, test id, or another deliberate unique contract.
The button has disabled on a custom element but Playwright still treats it as enabled Browsers do not apply native disabled behavior to arbitrary elements. Use a native control or expose the custom control’s disabled semantics with appropriate ARIA and behavior.
A stale element handle fails after rendering The framework replaced the node. Use a locator so Playwright can resolve the current element.

7. Reliability, performance, and timeout design

  • Prefer state-based assertions over fixed sleeps. A sleep waits a guessed duration; toBeEnabled() retries until the required state exists.
  • Use the narrowest stable locator you can explain to another maintainer.
  • Do not add a separate enabled assertion before every click. The click already performs enabled and other actionability checks.
  • Add the separate assertion when the test is specifically verifying that a control becomes enabled or when it improves diagnosis.
  • Keep timeout changes proportional to the real operation. Excessively long timeouts can hide a genuine application bug; very short timeouts create false failures.
  • When a test is slow, identify whether the delay is validation, rendering, an overlay, or a locator problem before increasing timeouts.

8. Or skip the browser setup

If your goal is a clean screenshot of a page after it has reached its usable state, ScreenshotNeo provides a single screenshot API request. Its capture flow accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each 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.

See the ScreenshotNeo API documentation for the available options, including full-page capture, element selectors, custom CSS and JavaScript, waits, request blocking, headers, cookies, device presets, PDFs, caching, signed links, async jobs, bulk capture, and usage reporting.

cURL

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

Python

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)

Node.js

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

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

9. FAQ

Should I use toBeEnabled() or click()?

Use toBeEnabled() when enabled state is a requirement you want to assert. Use click() alone when you only need to perform the action when the target is actionable.

Can I wait with locator.waitFor({ state: 'enabled' })?

No. Enabled is not one of waitFor()’s documented states. Use the retrying toBeEnabled() assertion.

Does visible mean enabled?

No. Visibility and enabled state are separate conditions. A visible button can still be disabled.

Does isEnabled() ever make sense?

Yes. Use it for an immediate boolean observation or branch. It is not suitable for waiting for an asynchronous state change.

Why does a click fail when toBeEnabled() passes?

Clicking also requires uniqueness, visibility, stability, and event reception. Check overlays, movement, and the locator match count.