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 });

Enter fullscreen mode Exit fullscreen mode

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.

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.

Enter fullscreen mode Exit fullscreen mode

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.

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

[5]<button id=submit-btn-5zhwmn invalid=false />
        Submit
load 1

Enter fullscreen mode Exit fullscreen mode

state before the page changes, load 1, Submit at index 5

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

Enter fullscreen mode Exit fullscreen mode

Error 210603: The snapshot belongs to a different page or tab than the
current one. Run 'browser-act state' again on the current page.

Enter fullscreen mode Exit fullscreen mode

Re-check, don't retry blind:

browser-act --session shot1 state

Enter fullscreen mode Exit fullscreen mode

[5]<button id=submit-btn-c66evj invalid=false />
        Submit
load 3

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

clicked=5

Enter fullscreen mode Exit fullscreen mode

reload, stale click producing Error 210603, fresh state at load 3, then the successful click

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.)