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

To wait until a control is enabled in Playwright Test, use the retrying web assertion await expect(locator).toBeEnabled(). It keeps checking the current element until the assertion passes or its timeout is reached. Use locator.isEnabled() only when you need the state right now; it returns a boolean immediately and does not wait. If your next operation is a click, await locator.click() already waits for enabled state as part of Playwright’s actionability checks.

The direct solution

A complete Playwright Test example looks like this:

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

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

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

toBeEnabled() is both a check and a synchronization point. Playwright retries the assertion while the locator is resolved against the page, so it can observe a control that is replaced during a re-render. Always await the assertion.

Choose the API that matches your intent

Approach Waits for a future enabled state? Use it when
expect(locator).toBeEnabled() Yes. The assertion retries until it passes or times out. You must prove that the control became enabled, or you want an explicit synchronization point.
locator.isEnabled() No. It reads the state at the instant it is called. You need an immediate boolean for branching or observation.
locator.click() Yes, as part of the complete actionability check. You only need to perform the click when the target is ready.

For example, this is an immediate observation, not a wait:

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.
const submit = page.getByRole('button', { name: 'Submit' });
const enabledNow = await submit.isEnabled();

if (enabledNow) {
  // This branch describes the state at this moment only.
  await submit.click();
}

If the application enables the button a moment later, isEnabled() will already have returned false. Replace it with await expect(submit).toBeEnabled() when the test is supposed to wait for that transition.

When a click is all you need

Playwright’s actionability model checks that a target is unique, visible, stable, able to receive events, and enabled before clicking. Therefore this often is sufficient:

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

The click waits for the target to become actionable and fails with a timeout if that never happens. Add a separate toBeEnabled() assertion when enabled state is itself part of the behavior under test, when it makes the failure message clearer, or when later steps depend on the state without immediately clicking.

Why locator.waitFor({ state: 'enabled' }) fails

locator.waitFor() does not have an enabled state. Its documented states are:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • attached: the element is present in the DOM.
  • detached: the element is no longer in the DOM.
  • visible: the element is visible.
  • hidden: the element is hidden or detached.

This is valid for visibility:

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

It does not establish enabled state. Use the web-first assertion instead:

await expect(submit).toBeEnabled();

Visibility and enabled state are separate conditions. A visible button can still be disabled, and an enabled button can be covered by an overlay and unable to receive a click.

What Playwright means by “enabled”

Playwright treats an element as enabled when it is not disabled according to the control’s semantics. The disabled rules include:

  • Native button, select, input, textarea, option, and optgroup elements with a disabled attribute.
  • Those native controls inside a disabled fieldset.
  • Descendants of an element with aria-disabled="true".

A native disabled attribute is not a universal switch. Browsers ignore that attribute on arbitrary elements such as a plain div. A custom control should expose an appropriate role and disabled semantics, commonly through ARIA, if it is intended to behave as a disabled widget.

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

Select a locator that survives UI changes

Prefer a locator based on the contract a user can recognize rather than a brittle DOM path:

  • getByRole() with an accessible name for buttons, links, checkboxes, and other controls.
  • getByLabel() for a form control associated with a label.
  • getByText() when visible text is the deliberate contract.
  • getByPlaceholder() when the placeholder is the intended identifier.
  • getByTestId() when your application defines a stable test-id contract.
const submit = page.getByRole('button', { name: 'Submit order' });
await expect(submit).toBeEnabled();

Locators are evaluated against the current DOM when used. If a framework removes a disabled button and inserts a new enabled one, the locator can resolve the replacement. Holding on to a stale element handle is less resilient than retaining the locator.

Waiting for a custom readiness condition

Use a direct enabled assertion for ordinary disabled-to-enabled transitions. For a condition that has no matching web assertion, locator.waitForFunction(fn) can retry a predicate while re-resolving the locator:

const exportControl = page.getByRole('button', { name: 'Export' });

await exportControl.waitForFunction((element) => {
  return element.getAttribute('data-export-ready') === 'true';
});
await exportControl.click();

A custom predicate is appropriate for application-specific state such as a readiness attribute. Do not replace toBeEnabled() with a predicate merely to wait for enabled state; the built-in assertion communicates that intent and supplies the standard retry behavior.

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

Reliable test patterns

Assert before a dependent operation

test('shows validation only after the form is ready', async ({ page }) => {
  await page.goto('https://example.com/signup');

  const continueButton = page.getByRole('button', { name: 'Continue' });
  await expect(continueButton).toBeEnabled();
  await continueButton.click();
  await expect(page.getByText('Account details')).toBeVisible();
});

Let the action perform the wait

test('continues when ready', async ({ page }) => {
  await page.goto('https://example.com/signup');
  await page.getByRole('button', { name: 'Continue' }).click();
});

Use an immediate branch deliberately

const optionalButton = page.getByRole('button', { name: 'Apply discount' });
if (await optionalButton.isEnabled()) {
  await optionalButton.click();
}

This last pattern intentionally does not wait. It is suitable when the test should take an optional path only if the control is ready at that instant.

Why fixed sleeps make this flaky

A fixed delay waits a chosen amount of time rather than the condition the test needs. If the application is slower than the delay, the test continues too early; if it is faster, every run carries unnecessary latency. A retrying assertion observes the actual state and stops as soon as it is correct. Replace patterns such as waitForTimeout() followed by a click with either toBeEnabled() or the click’s built-in actionability wait.

Troubleshooting timeouts

The assertion times out

First verify that the locator identifies the intended control and that the application really removes its disabled condition. Use a role and accessible name, inspect the rendered DOM, and check whether the page replaced the control with another element. If the transition is legitimately slow, review the assertion timeout configured for the test or project rather than inserting a fixed sleep. A timeout means the expected enabled state was not observed within the allowed period.

isEnabled() returns false unexpectedly

That method is a snapshot. It may have run before asynchronous data loaded or before a re-render completed. If the test should wait, use await expect(locator).toBeEnabled(). Also check for a native disabled attribute, a disabled ancestor fieldset, or aria-disabled="true" on an ancestor.

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

The button is enabled but the click still times out

Enabled is only one actionability requirement. The target may be hidden, moving, covered by an overlay, or ambiguous because the locator matches more than one element. Resolve the locator to one user-facing target, wait for the relevant overlay to disappear, and let click() perform its complete actionability checks.

waitFor({ state: 'enabled' }) is rejected

Enabled is not a supported waitFor state. Change it to await expect(locator).toBeEnabled(). Use waitFor({ state: 'visible' }) only when visibility is the condition you actually need.

A custom widget ignores disabled

If the target is not a native form control, a browser will not apply native disabled behavior just because a disabled attribute is present. Update the widget’s semantics and interaction logic, including an appropriate ARIA state where applicable, then select it with a deliberate role/name locator.

The locator stops matching after a framework re-render

Keep the locator, not a stale element reference, and use it at the point of assertion or action. Locator-based operations resolve against the current DOM, allowing Playwright to observe a replacement element.

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

Timeouts, diagnostics, and maintenance

Web-first assertions and actions fail at their configured timeout and report the condition that was not met. Keep an explicit enabled assertion when it documents a business rule, such as “the submit control must be enabled before the confirmation request.” Omit redundant assertions when the next action already expresses the requirement and the extra check adds no diagnostic value.

For new code, favor locator APIs and web assertions over page-level legacy methods. In particular, page-level isEnabled() and page.waitForSelector() are discouraged in favor of locator-based APIs and assertions. This keeps synchronization close to the element and avoids coordinating separate selector and state calls.

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 a clean image of a page rather than an interaction test, ScreenshotNeo provides a single screenshot request. It accepts consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be disabled. 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. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo documentation for request options and authentication. A direct call is:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

The same request from Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

And from 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}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo includes full-page and element captures, lazy-image loading, dark mode, device presets, arbitrary viewports, retina scale, PDF controls, HTML/CSS rendering, custom CSS and JavaScript, pre-capture clicks, selector hiding, selector/delay/network-idle waits, request blocking, headers, cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, chosen-TTL caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. Parameter names used by other screenshot APIs also work, easing migration.

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is included on every plan, and yearly billing provides two months free. Create a free ScreenshotNeo account to start without a card.

Practical decision checklist

  • Need to verify a transition to enabled? Use await expect(locator).toBeEnabled().
  • Need the state right now for a conditional? Use await locator.isEnabled().
  • Only need to click when ready? Call await locator.click().
  • Need visibility, attachment, or detachment instead? Use the corresponding locator.waitFor() state.
  • Need an application-specific predicate? Use locator.waitForFunction(fn), but not for a normal enabled check.
  • Seeing flakiness? Remove fixed sleeps, improve the locator, and investigate overlays or disabled ancestors.

Frequently Asked Questions

Does toBeEnabled() need to be awaited?

Yes. The assertion performs asynchronous retries, so call it as await expect(locator).toBeEnabled().

Can I wait for enabled and visible in one assertion?

Treat them as separate requirements: use toBeEnabled() for enabled state and a visibility assertion or actionability check for visibility.

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

Should every click have a preceding enabled assertion?

No. A click already waits for enabled state and the other actionability checks. Add the assertion when the enabled transition is an explicit behavior you want to verify or diagnose.

What happens when a locator matches multiple buttons?

The action cannot establish a unique target. Refine the role, accessible name, label, or test-id so the locator identifies the intended element.

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.