
When a selector breaks in a browser automation workflow, the selector itself isn’t always the problem. The element may have changed, the page may be in a different state, or the workflow may have changed altogether. Timeouts, ambiguous matches, and stale element references can all look like selector failures, but they point to different problems and require different fixes.
Playwright locators are well suited to handling changes at the element level. They can resolve elements again after a page re-renders, remain resilient to small changes in the DOM or surrounding containers, and avoid relying on brittle CSS classes that may change frequently.
What locators can’t tell you is whether the original workflow still makes sense. If a button now leads to a different flow, an extra confirmation step has been added, or the path to the expected result has changed, rewriting the locator won’t solve the underlying problem. At that point, it’s the workflow that needs to change, not the selector.
If you’re maintaining a test suite rather than a scraper focused on data collection, the differences between Playwright and Cypress also come down to factors such as test reliability, debugging workflows, and migration costs.
Some issues that look like stale element references can also be related to browser sessions. Persistent browser sessions across agent runs explains how browser state can persist beyond a single task and why that matters for subsequent automation.
This is where ego (lite) takes a different approach. Instead of repeatedly executing a predefined sequence of steps, it starts with the task goal and decides what to do next based on the browser’s current page and state. If the original path is no longer available, the agent can adapt to what it sees and find another way to complete the task.
That doesn’t make deterministic browser automation obsolete. When a workflow is stable, the steps are well understood, and the same actions need to run repeatedly, Playwright remains a strong choice for repeatable automation and regression testing. The boundary is straightforward: if the element changes, fix the selector; if the workflow changes, replan the path.
Whether a headless browser can faithfully reproduce the browser environment you use during debugging is a separate question. For a deeper look at that distinction, see Headless vs. Real Browsers for AI Agents.
What a broken selector is actually telling you
Three error classes cover almost everything people call a broken selector, and they point in completely different directions.
A timeout waiting for the element means the query resolved to nothing. The page did not render it, it rendered it inside a frame your locator never entered, it is still behind a loading state, or the identifier genuinely changed. Treating this as a naming problem is the most common diagnostic mistake, because a missing element and a renamed element produce the same symptom.
A strict mode violation means the opposite: the query now matches more than one element and the tool refuses to guess. Playwright resolves locators in strict mode by default and throws as soon as a query matches several elements, without waiting. That matters diagnostically, because an ambiguity error and a timeout have nothing in common, even though both arrive as a failed step.
A detached or stale reference failure means you held on to an element from an earlier point in the run and the page has since replaced it. The element node was discarded during a re-render, so the reference is gone even though the control is still on screen and still correct.
The mechanism is documented in the locators guide, the Locator API reference, and the actionability checks. Reading the error class before touching the query is the whole first step, and it is the step most people skip.
Three classes of selector failure
Once you know which error class you have, the next question is which assumption failed. Sort the failure into one of three layers, and the correct response follows from the layer rather than from the symptom.
| Failure class | What actually broke | Correct response |
|---|---|---|
| Durable identifier failure | The attribute you matched on was an implementation detail: a generated class, a positional index, a deep CSS path. | Fix the locator. Move to a role plus accessible name, a test id, or a stable container scope. This is the only class where a selector fix is the right repair. |
| Page-state failure | The query is reasonable, but it ran before the page reached the state it assumes, or against a responsive variant with different markup. | Fix the wait, the frame scope, or the viewport assumption. Changing the selector leaves the real cause in place. |
| Workflow-assumption failure | The step itself is wrong. The control moved into another flow, the task now needs an extra confirmation, or the route to the outcome changed. | A person updates the workflow. No locator change is correct here, and a patch that makes the step pass anyway is the most dangerous outcome. |
The practical value of this table is that two of its three rows should stop you from editing a selector. In public test-maintenance surveys, brittle selectors are typically blamed for roughly a quarter to a third of failures, with timing and interaction problems accounting for a comparable or larger share. That ratio is the tell: if almost every failure in your suite is filed as a selector problem, the classifications are wrong somewhere.
Why a locator survives what a handle does not
A locator is not a cached reference to an element. It is a query description that stays unresolved until the moment it is used, and it re-resolves against the live page before every action. A handle is the opposite: it points at one specific node captured at one specific moment. This single difference explains most of the surviving-versus-breaking behaviour people attribute to selector quality.
// Brittle: stores a node from an earlier render. A later re-render detaches it.
const button = await page.$("div.toolbar > button:nth-child(2)");
await button.click();
// Durable: re-resolves before every action, and survives a re-render.
const button = page
.getByRole("navigation", { name: "Issue actions" })
.getByRole("button", { name: "Close issue" });
await button.click();Two documented details are worth knowing before you rewrite anything. First, role locators query the accessibility tree, which is why they survive wrapper elements and renamed generated classes: they match what the control is and what it is called, not where it sits. Second, strict mode applies to locators but not to the querying methods, so a fix that swaps a locator for a raw query may look like it worked while quietly removing a guardrail.
What a durable locator still cannot do is decide that the step is no longer the right step. A renamed class is a locator problem. A control that moved into a different flow is a task problem, and the difference between those two is exactly where most selector debugging goes wrong.
Five failure cases you can reproduce
Each case below is reproducible on any page you control. The point of working through them is to separate what a locator fix genuinely repairs from what only a person can repair.
1. A generated class name changes
Rename the class on a button and anything matching the old class stops resolving. A role locator with an accessible name is unaffected, because the class was never part of the control's identity. This is the case a locator fix fully repairs, and it is the one people generalise from when they conclude that stable locators solve UI change.
2. A wrapper element is added
Insert a div around the control. Any descendant or child combinator path breaks, and any positional selector shifts. A role locator or a test id still resolves. This is the same class as case one, and the fix is the same, which is precisely why the first two cases are a misleading sample of the problem.
3. The component re-renders and replaces the node
Force the component to unmount and remount. A code path holding an element reference from before the re-render fails with a detached reference; a code path that re-resolves its locator at the moment of use continues. Actionability checks handle the closely related timing case by waiting for the element to be attached, visible, stable, enabled, and receiving events rather than failing immediately, which is why some timing problems disappear when you move to locators even though nothing about identity changed.
4. The accessible name changes
Change a button from Save to Save draft, or add an icon that alters the computed name. A role locator matching the old name now fails, and the failure is more informative than a class rename because it usually reflects a real product change. If the label is owned by content or translation and will keep moving, a test id is the honest choice here, not another text match.
5. The control moves into a new flow
Require an extra confirmation step, or move the action behind a menu it was not behind before. Every locator technique fails here for the same reason: the old step is no longer valid. You can point a correct locator at a correct element inside a step that should not exist, and the run will still not complete the task. A person has to change the workflow, and a supervised agent can often complete the task anyway by reading the current page and choosing a different route.
One more case behaves like the fifth even when the interface never changed: the page is a third-party site you do not own. Its maintainers can restructure it without warning, and no amount of locator quality protects you, because the contract you were relying on was never yours.
The same task, run three ways
Everything above is easier to judge against a real run than against a description. We pointed three different browser-automation setups at one identical task on a site none of us controls: find the three oldest open issues matching selector language:TypeScript in GitHub's public issue search while logged out, then read the title, canonical URL, opened date, state, and first label for each. The task is deliberately ordinary. What matters is not what the tools did, because all three eventually returned the correct three issues. It is how each one failed on the way there. The three blocks below take the setups in turn, starting with the same task before anything had gone wrong.

Playwright MCP with deliberately naive locators
This run was written the way a first-draft script gets written: positional selectors, generated class names, and element handles resolved once and reused across later steps. It reached the right answer, but only by rewriting its locators partway through, and along the way it produced two readings that failed without looking like failures. It reported the first two result rows with their author and date metadata missing entirely, having read them the moment the sort order changed and before the rows re-rendered, which is a page-state failure wearing a selector problem's clothes. It also read the issue heading with a positional query and got back the repository's sponsor name, a plausible string in the right shape that no tool would have flagged.





Browser MCP with accessibility snapshots
Browser MCP has no locator API. It works from accessibility snapshots and the element references drawn from them, which puts it in a different position on the same failure. References were invalidated on every navigation and could not be carried across a state change, and the reference namespace advanced and then rolled back to an earlier generation, so the same prefix could name a different element. The run was pointed at that hazard deliberately and could not reproduce a silent retarget: the tool refuses rather than repointing. That is the opposite posture to a self-healing repair, and for a suite the safer one. Failing closed costs you a step; failing open costs you the failure signal that would have told you the page changed.



ego (lite) driven by the task objective
This run took the goal rather than a step list, so it had no fixed locators to break and no step to fail on. It hit the failure the other two could only report indirectly: the logged-out search URL renders an interstitial with no issues tab, no sort control, and no search field, which means the step as written no longer existed. There was no element to point a better locator at. The Agent read the page, established that the step was gone rather than merely mismatched, and drove the same goal through the URL query form. It then reported the deviation instead of quietly presenting the result as a clean run, and returned all three issues with canonical URLs, ISO opened dates, and their first labels.



Set against the Playwright block above, the contrast is not that one setup was correct and another was not. Both finished. The difference is which failure each could see. A locator-based run can tell you that its query stopped resolving; it cannot tell you that the step it was resolving for should no longer exist, and it will happily report a stale value when its handle survives a render it should not have. The interesting result across all three is therefore about the shape of the failure each approach produces, not about which one came first.
Three limits apply to everything in this section. These are single observed sessions from September 15, 2026, not a benchmark: one run per setup, on one public site, in one browser session, and the result counts and issue details above are observations about a live repository rather than stable facts. The three setups also differ in more than their selector strategy, since each used its own model, reasoning effort, and tooling, so the comparison isolates failure mode rather than overall capability. And no timing is reported here at all, because the three runs were not instrumented the same way and one of them included a failed first attempt. Elapsed wall-clock time on a single task like this is dominated by how long the agent takes to decide what to do next, not by anything about the tool, so a number drawn from it would measure the harness rather than the approach.
How do you verify the fix actually holds?
A passing test after a selector change proves very little on its own. Four checks are what separate a verified fix from a step that happens to be green.
- Confirm the locator resolves to the element you intended, not merely to something clickable. In Playwright, strict mode already fails on multiple matches, so a fix that relies on first() is removing the check rather than satisfying it.
- Confirm the assertion actually executed. A step that silently skipped its verification looks identical to one that passed it.
- Re-run against a fresh page load and a clean session. A fix validated on a page that was already in the right state may not survive the real entry path.
- Repeat the run enough times to distinguish a fix from luck. A failure that appears in one run in five is not fixed by a change that passed once.
Assertion behaviour matters for the second check. Web-first assertions on a locator retry until the condition is met or the timeout expires, while reading a value directly from a locator does not retry. Moving a check from one form to the other changes whether the test waits at all, which is documented in the test assertions guide.
What self-healing gets right and where it fails
Self-healing tooling watches a failing locator and proposes a replacement, usually by scoring candidate elements for a similarity to the original. It addresses real pain, because durable-identifier failures are common and often mechanical to repair. The published claims around it, though, deserve a second look, and outside the vendor material the reception is more measured than the marketing suggests.
The structural problem is that a replacement locator is a guess about intent, and the tool has access to the old query rather than to the reason the query existed. It can find an element that looks like the previous one. It cannot tell you that the step should have been removed, that the flow now requires confirmation, or that the task moved elsewhere entirely. When it guesses wrong and the step still passes, the outcome is the one described above: a green run with no protection behind it.
That is why the useful posture is proposal, not acceptance. A suggested locator should arrive as a diff with the element it matched and a reason to believe it is the same target, and a person should confirm it. The arithmetic explains why: if a single step succeeds 99.9 percent of the time, a thousand-step suite fails most of its runs end to end, and at 99.97 percent per step it still fails roughly a quarter of them. Long deterministic suites do not fail because someone wrote one bad selector. They fail because small per-step error rates accumulate, and an unverified automatic repair adds to that rate instead of reducing it.
Where ego (lite) fits, and where it does not
This is where an objective-driven agent differs from a script. Rather than replaying a fixed sequence of steps, ego (lite) receives the task, observes the browser's visible state, chooses the next action, and replans when the page or the objective changes. That is what makes the fifth failure case survivable: when a control moves into another flow, the Agent can reassess the page and find a workable path instead of stopping on a step that no longer applies. The quick start and the source repository document what the Agent does and how to run it.
Two boundaries belong in the same paragraph as that capability. First, replanning is inference from what is on screen, so a page that hides the state it depends on, or a flow that needs credentials the Agent does not have, can still defeat it. It is not a guarantee that every change is absorbed. Second, and more important for anyone maintaining a suite: an agent run is not a regression test. It is exploratory, it produces a result rather than a repeatable assertion, and it should not be substituted for a deterministic check on a path you already understand.
The division of labour is straightforward. Use ego (lite) for exploratory work, for pages you do not control, and for flows that are still changing shape, where the goal is to finish the task today. Use a deterministic framework once a workflow has stabilized, the interface contract is yours, and the value is in catching regressions rather than in completing the task once.
One caveat belongs in this section rather than in a marketing line. The agent path spent its first attempt on the wrong page, because the step it was given no longer existed, and had to replan. That is the cost of the capability being described: when a step is broken, an agent pays for the diagnosis before it can route around it, while a scripted run fails immediately and cheaply. On a stable path that trade is a loss, and on a broken one it is the whole point. That is also why no elapsed-time comparison appears in this article. A single run per setup cannot support one, and the honest reading of the runs above is about which failures each approach survives, not which finishes first.
When should you stop fixing selectors?
Stop when the repair you are about to make no longer corresponds to a change in the page. If the locator broke because an attribute changed, fixing it is a real repair. If it broke because the step is no longer the right step, the mechanical fix produces a test that passes while the task goes uncompleted, which is worse than a failing test because it removes the alert.
There are further signals. Repeated failures on the same step across unrelated changes mean the step is matched on something the page owner treats as private. Locator maintenance consuming more time than the workflow delivers in value means the task is not stable enough to be encoded yet. And a step you keep loosening rather than fixing has already told you that its identity is not something the page guarantees, which is a conversation about the contract rather than about selectors.
When a durable locator is genuinely the answer, the documented guidance in the best practices and other locators pages covers choosing what to match on, scoping to a frame, and working with open shadow roots, where CSS locators pierce the boundary and XPath does not. The frames guide covers the case where an element exists but your locator never entered the frame it lives in, which is a page-state failure wearing a selector costume.
FAQ
Why do my selectors keep breaking in browser automation?
Usually because they match implementation details. A generated class, a positional index, or a CSS path that encodes nesting all change for reasons unrelated to the task. Matching on a role with an accessible name, or on a test id the team maintains deliberately, removes most of that exposure. If failures continue after that, the cause has moved one layer up.
Should I use CSS selectors or XPath in browser automation?
Prefer neither when a semantic locator is available. Both encode structure rather than meaning, so both break when layout changes. XPath additionally cannot pierce an open shadow root, where CSS can. Reach for either as a deliberate fallback when the element has no accessible semantics to match on, and prefer a maintained test id over a structural path.
Can self-healing locators replace maintenance?
No. They can propose a replacement for an identifier that changed. They cannot tell you that the step should no longer exist, that the flow gained a confirmation, or that a task moved elsewhere. Treat the proposal as a diff to review, and confirm which element it matched before accepting it.
What is the difference between a locator and a selector?
A selector is a string that describes how to find something. A locator is an object that holds that description, stays unresolved, and re-resolves against the live page each time it is used. The distinction is why a locator keeps working after the page re-renders while a reference to a previously captured element does not.
When is an AI agent a better answer than fixing the selector?
When the workflow is still changing, when the site is not yours, or when the goal is to complete a task once rather than to protect a path the team already understands. In those situations an agent can reassess the page and choose a different route, and a person can observe the run and take over. Once the path stabilizes, a deterministic test is the better instrument.
How many times should I re-run a test after fixing a selector?
Enough to separate a fix from a coincidence, and against a fresh page load rather than a page already sitting in the right state. A change that passes once has demonstrated very little; the same change passing repeatedly from a clean start has demonstrated something. If the failure was intermittent before, only repetition tells you whether it is gone.
Why does a locator pass locally but fail in CI?
Usually because the page is slower or the viewport is different there. A locator that resolves on a warm local browser can race a cold CI browser, and a step that depends on a hover or a scroll behaves differently at a smaller size. Reproduce the failure with the same headless setting and viewport as CI before you change the selector.
Should I add a wait before every selector interaction?
No. Playwright's own guidance is that waiting is not required for buttons, links, and inputs, and wrapping every interaction in an explicit wait makes runs slower and hides the real race. Wait on a specific condition you can name, and only where the step genuinely depends on it.
When should I stop fixing selectors and rewrite the workflow?
When the same step has broken a third time for a different reason, or when the fix requires adding state the page did not have. At that point the assumption behind the step is the problem rather than the query, and a replanned route is cheaper than another locator patch.
Do shadow DOM and iframes break selectors differently?
Yes, and for different reasons. A frame is a separate document, so a selector that works in the page returns nothing inside it until you enter the frame. A shadow root hides its internals from ordinary queries, and a tool that pierces it automatically can still match the wrong node if the host moved.



