Disclosure: BrowserAct sponsored this piece. The BrowserAct links below are affiliate-tracked — I get credit if you sign up through them.
I keep hitting the same failure building agent workflows in Claude Code: the agent captures a selector once, the page re-renders with a new build hash, and the next run breaks on an element that's still right there on the screen — just under a different id. BrowserAct is a browser automation platform built for AI agents — real browser control, persistent browser identity, task sessions, verification handling, and human handoff, all behind one CLI. This test is about one narrow slice of that: how it handles a page whose ids and classes change under you, using Claude Code to drive it.
I tested that against a page I built myself, compared directly against raw Playwright.
The problem, concretely
Here's the shape of it. A frontend re-renders with fresh CSS-module or styled-components hashes on every deploy. You write:
const id = await page.locator("button").getAttribute("id");
await page.reload();
await page.locator(`#${id}`).click({ timeout: 3000 });
That works the day you write it. It breaks the next time the build hash changes, and the failure you get is a bare timeout with no explanation:
locator.click: Timeout 3000ms exceeded.
Playwright's own role/text locators fix this specific case — getByRole("button", { name: "Submit" }) survives id/class churn fine, because it never depended on the hash in the first place. That's a real fix, not a workaround. What it doesn't give you is a representation of the page an agent can reason about turn by turn, or a signal that says "the page under you just changed, stop and re-check." That's the gap BrowserAct is actually filling.
The interaction model
BrowserAct doesn't hand Claude Code a selector at all. The loop is: read the current state, choose an action from what's actually there, execute it, reassess.
state -> indexed list of interactive elements, as of right now
click <index> -> act on one of them
state -> read again, because the page may have changed
The index is scoped to that one snapshot. It's not a selector, not a stable id — it's a pointer into "what state just returned," and it expires the moment the page does something a new state call would notice.
Setting up BrowserAct
uv tool install browser-act-cli --python 3.12
browser-act --version
browser-act browser create --name "dom-drift-test" --type chrome --desc "local churn test"
browser-act browser list
browser list after creation is how you get the browser id you'll pass to every session command — it's not returned anywhere else. I used the chrome browser type (a local, blank browser, immediate to create) rather than stealth, since this test is about selector churn on a page I control, not anti-bot evasion — stealth requires an API key and a billed purchase flow that has nothing to do with what I was testing. Creating a local chrome browser completed immediately with no purchase page involved.
Versions tested: browser-act-cli v1.1.0, Playwright 1.61.1, Python 3.12.13. To reproduce this: the test page is a small Node server that reshuffles element ids, classes, and order on every reload — no third-party site involved — plus the Playwright scripts run against the same page.
Skill source: browser-act/skills. Installation details: docs/installation.md.
browser-act --session domtest browser open <browser-id> http://localhost:8934
browser-act --session domtest state
state returns an indexed list, not a selector:
[1]<button class=item-r8k7gx invalid=false />
Bravo
[2]<button class=item-cr2ajb invalid=false />
Charlie
[5]<button id=submit-btn-ckeewz invalid=false />
Submit
You act on the index (click 5), not the class or id, so the build-hash problem doesn't apply to it — there's no hash in the reference to go stale.
The actual comparison
I built a local test page that reshuffles element ids and item order on every reload, specifically to force this failure. Playwright's role locators, as noted above, survive that fine. The difference isn't that Playwright can't handle churn — it's what happens when you act on a reference that's gone stale anyway, whether from a captured selector or an old snapshot:
Playwright, selector captured once, reused after reload:
FAILED clicking #submit-btn-g9mpcm:
locator.click: Timeout 3000ms exceeded.
BrowserAct, index captured once, reused after reload:
Error 210603: The snapshot belongs to a different page or tab than the
current one. Run 'browser-act state' again on the current page.
Both refuse to silently click the wrong thing. But BrowserAct's error names the exact cause and the exact fix — re-run state — where Playwright's is a generic timeout you already have to know how to interpret.
The recovery flow, actually run
That error is only useful if what follows it actually works. Here's the full loop, one continuous session, real output, no steps skipped — the error and the successful recovery from it, back to back:
browser-act --session shot1 browser open <browser-id> http://localhost:8934
browser-act --session shot1 state
[5]<button id=submit-btn-5zhwmn invalid=false />
Submit
load 1
Page changes, and the old reference is used anyway — the error from earlier, live:
browser-act --session shot1 reload
browser-act --session shot1 click 5
Error 210603: The snapshot belongs to a different page or tab than the
current one. Run 'browser-act state' again on the current page.
Re-check, don't retry blind:
browser-act --session shot1 state
[5]<button id=submit-btn-c66evj invalid=false />
Submit
load 3
The id changed (submit-btn-5zhwmn to submit-btn-c66evj), and the fresh state call caught it. Acting on the new index closes the loop:
browser-act --session shot1 click 5
clicked=5
State, act, hit the error, reassess, act again — completed, in the same session the error happened in.
What BrowserAct Actually Solved
It reduced my dependence on generated ids and CSS classes to zero for anything clickable, and gave Claude Code a predictable recovery path the moment the page changed underneath it: re-run state, act on what's actually there now.
Two real limits, not softened. state only indexes interactive elements — in my test, the list items only got indices once I made them buttons. For plain text or document content, the right tool isn't state at all, it's BrowserAct's content-extraction commands (get markdown, get text <index>) — treating state as a universal page parser is the wrong mental model. And separately: the title field in state's own output lagged behind the actual page content in one of my runs — the visible elements had already changed, title hadn't caught up yet. Worth knowing if you're tempted to key any check off title specifically.
One Check I Still Kept Explicit
BrowserAct solved the selector problem. It didn't remove the need to think about website authentication separately. Three different things are in play here, and it's worth naming them precisely: the browser identity (the chrome browser instance itself), the BrowserAct task session (--session domtest), and the target website's own authentication session.
In an earlier run against the same test page, the website authentication state expired while the BrowserAct task session remained active — state kept responding normally, it just started describing a "please sign in again" page instead of the dashboard. BrowserAct exposed that changed page state clearly enough for Claude Code to decide what to do next: continue, re-authenticate, or hand off to a human. It didn't decide that automatically, and I don't think it should — that's still a call I want made explicitly, not inferred.
Where this leaves it
Claude Code and BrowserAct solved the actual failure mode I set out to test: brittle selector maintenance replaced with a workflow built on current page state, indexed actions, and an explicit recovery step when the ground shifts. That's a narrower claim than "browser automation solved," and it's the one I can actually stand behind.
Try BrowserAct: browseract.ai/Daniel
BrowserAct Skills: github.com/browser-act/skills
(Both links above are affiliate-tracked to my account.)


Top comments (4)
This is a clever abstraction. What stood out to me wasn't the indexed actions themselves, but that BrowserAct treats every page snapshot as disposable. That's a much healthier contract for AI agents than pretending selectors are durable across renders.
We've run into a similar pattern at IT Path Solutions when production AI workflows interact with SPAs it's rarely the click that fails, it's the assumption that yesterday's view of the DOM is still valid. Re-reading state before taking the next action has proven much more reliable than trying to make selectors increasingly "smart."
I'd be interested in seeing how this approach behaves on highly dynamic dashboards where multiple regions update independently (live tables, notifications, streaming widgets). That's where most browser agents still struggle, even after selector issues are solved.
Disposable snapshot is the real trick here. State is pull, not push: it only refreshes when you call it, so a region updating on its own (a notification stream, a live table) won't show up until you ask again.
On a single click-flow that's fine, the agent naturally re-checks after every action. On a dashboard with independent regions, nothing forces that re-check. You'd need to poll on a timer, not just after your own moves. Curious if BrowserAct has anything built for that, or if it's still on the caller to handle.
Yeah no more tinkering with DOM selectors, AI figures it all out, that's very convenient - but, at the cost of token usage, and (I can imagine) a little bit of added latency ...
Real cost and a specific one. Playwright's role locators already kill the id/class churn problem for free . no LLM, no round trip.
What BrowserAct is actually charging tokens and latency for is the reassessment step: read state, reason over the element list, act. So the cost scales with how often the page shifts under you, not with the page's size. Worth asking though . on a stable page, is that reassessment ever firing when nothing changed, or does BrowserAct only re-state after a failure??