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.
#1 Best Overall
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:
Quick wins for a faster PC:
Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Repair Windows errors before they cause bigger problemsFix Now →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.
Rank #2
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, andoptgroupelements with adisabledattribute. - 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.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Fix the driver behind crashes, sound loss and screen glitches3Repair Windows errors before they cause bigger problemsSelect 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.
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallReliable 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.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →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.
Rank #4
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.
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.
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:
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.
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.
Quick Recap
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.

