ego (lite) is just a browser, ego is your personal agent across devices.
Join waitlist
PlaywrightSelectorsLocatorsTest automationFlaky tests

How to handle selectors breaking in browser automation

Sep 15, 202615 min read
Illustrated robot at a signpost marked login, navigate, click, and complete, beside a second figure holding a map and a loading browser window

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 classWhat actually brokeCorrect response
Durable identifier failureThe 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 failureThe 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 failureThe 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.

A logged-out GitHub issue search for the query selector showing over a million results sorted by best match, beside the browser automation terminal that had just started the same task
How the task looked before anything went wrong. The search surface opens on best match with the query unqualified, and narrowing it to open TypeScript issues sorted oldest first is where each setup's assumptions started to diverge.

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.

A browser automation terminal showing that a read taken immediately after changing sort order returned rows missing their author and date metadata
A page-state failure that presents as a selector problem. The read was taken the moment the sort order changed, before the result rows finished re-rendering, so the first two rows came back missing their author and date metadata entirely while the third row was complete. Nothing was misnamed. The query simply ran against a page mid-transition.
A browser automation terminal showing that a positional heading read returned the repository's sponsor name instead of the issue title
False success, caught only by cross-checking. The heading read returned the repository's sponsor name because the page carries two heading elements and the first is an overlay. Nothing about the returned value looked wrong, so the failure was invisible until it was compared against a second locator.
A browser automation terminal showing that detached element handles still returned the previous row's text without raising an error, beside the GitHub issue search page they were read from
The most damaging form of the same problem, and the reason this class is worth naming. Handles resolved before the sort order changed were reused afterwards, and the tool reported isConnected: false while still returning the old rows' text with no error. This is what failing open looks like from the outside: a completed step, a plausible value, and nothing anywhere that says the data is stale.
A browser automation session's final report listing each locator as it was first drafted, whether it resolved, and which failure it produced
The same run's own locator accounting. The search input resolved to null, the result rows matched zero elements on the issue pages, the heading and label reads resolved to the wrong elements, and the opened-date read needed a rewrite once the text and attribute forms disagreed by a day. Only the state read survived unchanged, and it did so through a build hash rather than a contract.
A browser automation terminal reporting that the page contains zero input or textarea elements, beside the GitHub issue search results it was pointed at
The first entry in that accounting, and the one that reframes the whole section. The query for input or textarea elements returned zero matches on both attempts, because the search surface GitHub renders to a logged-out visitor has no text input at all. The identifier was not stale and not renamed. There was nothing to resolve against, which is a durable-identifier failure of an unusual shape and, for a locator-based approach, the wrong thing to be repairing.

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.

A browser automation session noting that its element reference namespace advanced and then rolled back to an earlier generation after navigation
Why snapshot-based references do not survive a state change. The reference namespace advances with each snapshot and then rolls back to an earlier generation after navigation, so the same prefix can name a different element. The run tracked that deliberately and still could not get one to retarget silently.
A browser automation session refusing to fabricate findings while no tab is connected, then confirming the connection and its reference prefix after the extension is attached
The same posture before the run could start. With no tab attached, the session states that it has no element references, no snapshot, and no page state, that the missing connection is a workflow-assumption problem rather than something a locator could repair, and that it will not fabricate the findings it was asked for. Refusing to answer is the correct output here, and it is the behaviour that makes the negative result below credible.
A browser automation session concluding that its element references failed closed on stale snapshots rather than silently pointing at a different element
The conclusion of that attempt, in the run's own words. Given an explicit instruction to try to make a stale reference retarget, it could not, and said so: references were invalidated instead of being silently repointed. A tool that fails loudly is weaker on paper and safer in a suite.

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.

An ego (lite) agent Space on its first attempt, stating that the logged-out search page is an interstitial and that it will drive the goal through the URL query form instead
The replanning step itself, mid-run. Faced with a page that had no issues tab to click, the agent states the problem in plain terms rather than retrying a locator: the logged-out search URL is an interstitial, not the results interface, so it will navigate with the query in the URL instead. The Space name and the agent-control indicator stay visible throughout, which is what makes a decision like this reviewable while it is happening rather than after.
An ego (lite) agent session reporting the three oldest open issues with canonical URLs and ISO opened dates, beside the GitHub search results page it read
The fifth failure case in the wild, and the one no locator technique could have resolved. The report states that the search surface renders only a Search GitHub textbox, a tip banner, and footer links, with no issues tab, no sort control, and no result list, and that the goal was moved into the URL query form. It then returns all three issues with canonical URLs, ISO opened dates, and their first labels, having stated the deviation rather than hiding it.
The same ego (lite) Space later in the run, with the agent-control indicator visible and a Take over control beside it, showing an issue page open on ankit/stylebot
The same Space further into the run, having reached the destination. Every issue was opened as a real page rather than read from a search snippet, and the state was confirmed per issue rather than assumed from the list. The Take over control sitting beside the run is the point being made in the next section: supervision costs a glance here, not a rewrite afterwards.

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.

  1. 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.
  2. Confirm the assertion actually executed. A step that silently skipped its verification looks identical to one that passed it.
  3. 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.
  4. 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.