You are choosing a browser automation stack, and every comparison you find says the same thing: Playwright is fast and deterministic, Stagehand is flexible and survives UI changes. Most of those comparisons also describe Stagehand as a wrapper around Playwright, which has been factually wrong since October 2025.
Stagehand v3 is a CDP-native framework, no longer built on Playwright. Its AI actions survive UI changes that break selectors, but each uncached call costs an LLM inference and seconds of latency. Playwright steps are deterministic and free. In production you want both: Playwright for known paths, act() for volatile spots, and caching to make the AI parts replayable.
The rest of this post is the version of the comparison that actually matters when you run this stuff unattended: what v3 is under the hood, where AI actions genuinely lose, the hybrid wiring, and the cache-key mechanics that decide whether your test is deterministic or not.
What Stagehand v3 actually is
As of Stagehand 3.6, playwright-core is not a dependency. It appears in the npm manifest only as an optional peer dependency; the real dependencies are devtools-protocol, ws, and the LLM SDKs. Since the v3 release on October 29, 2025, Stagehand talks to the browser directly over the Chrome DevTools Protocol. Browserbase claims v3 is 44.11% faster on average across iframe and shadow-root interactions than v2, by their own benchmark.
The companion post, "Why we're graduating from Playwright", gives three engineering reasons, and they are worth understanding because they explain the whole design:
- Playwright's auto-waiting is built for testing, not automation. Actionability checks that make E2E tests reliable add latency when you are driving a browser thousands of times a day.
- Playwright lacks stable frame identifiers. Building AI context (accessibility trees, DOM snapshots, network events) requires fine-grained per-frame CDP routing that Playwright does not expose.
- Memory growth in long-running sessions, plus version-specific regressions.
Notably, the same post praises Playwright's mental model and ecosystem and promises full compatibility, so you can use any Playwright API alongside Stagehand. This is not a divorce. It is a re-layering.
On top of CDP, v3 exposes four primitives, all methods on the stagehand instance (in v2 they lived on page; that pattern is dead):
act()takes a natural-language instruction or a deterministicActionobject and performs one interaction.observe()returns candidateAction[]objects (XPath selector, description, method, arguments) without executing anything.extract()pulls structured data, optionally validated against a Zod schema.agent()runs multi-step autonomous tasks, withmaxStepsdefaulting to 20.
Iframes and shadow DOM are handled automatically in v3; the v2 iframes: true flag is gone.
Where AI actions beat selectors, and where they lose
The wins are real. An instruction like "click the checkout button" keeps working when a designer renames the class, moves the button into a modal, or wraps it in a new component, because the model reasons over the live accessibility tree rather than a hardcoded path. For genuinely unstructured work (find the cheapest shipping option, dismiss whatever popup appeared), there is no selector to write in the first place.
Now the losses, and the skeptics said it best. When Stagehand hit Show HN in January 2025 (v1 era, so read the quotes in that context), the top objections were exactly the production concerns:
- kevmo314 asked, "How do you avoid this becoming horrendously expensive per run?"
- mpalmer said he could not plausibly argue for LLMs at runtime in his work test suite; he wanted Stagehand as a code generator, not a runtime dependency.
- Klaster_1 pointed out that selectors break easily, but building reliable, deterministic tests is the hard part either way.
The maintainers did not dodge. hackgician admitted the caching at the time was basic prompt caching that only held if the DOM did not change, framed the product as letting you choose how much to rely on AI versus repeatable Playwright code, and opened an issue on the repeatability of extract().
Eighteen months later, everything that thread demanded (caching, determinism, fine-grained hybrid control) is literally a docs page titled Deterministic Agent Scripts. The objections were valid, and the answer shipped. But the fundamentals have not changed: an uncached act() is an LLM inference over an accessibility-tree-sized prompt. That is nondeterministic, slow relative to a selector, and metered. You do not engineer around that by hoping. You engineer around it with architecture.
The hybrid pattern
The decision rule we use:
| Situation | Tool |
|---|---|
| Known, stable path (login form, nav) | Playwright selectors, native speed, $0 |
| Volatile UI (redesigns, A/B tests, third-party widgets) | act() |
| You want to inspect before you click | observe(), then act(action) |
| Genuinely unstructured, multi-step task | agent() with maxSteps capped |
The wiring is the part nobody covers. Stagehand 3.6 exposes connectURL(), and Playwright connects to the same browser over CDP. Install both @browserbasehq/stagehand and playwright-core, then:
tests/checkout.ts
import { Stagehand } from "@browserbasehq/stagehand";
import { chromium } from "playwright-core";
const stagehand = new Stagehand({
env: "BROWSERBASE", // or "LOCAL"
model: "google/gemini-2.5-flash",
cacheDir: "cache/checkout-test", // local action cache
});
await stagehand.init();
// Playwright drives the same browser session
const browser = await chromium.connectOverCDP({
wsEndpoint: stagehand.connectURL(),
});
const pwPage = browser.contexts()[0].pages()[0];
// Known path: deterministic, milliseconds, zero inference
await pwPage.goto("https://app.example.com/login");
await pwPage.getByLabel("Email").fill(process.env.EMAIL!);
// Volatile spot: AI action on the same page, secret never sent to the LLM
const r = await stagehand.act("enter %password% in the password field", {
variables: { password: process.env.PASSWORD! },
page: pwPage,
});
console.log(r.cacheStatus); // "HIT" | "MISS"Two details matter here. First, variables substitutes values after inference, so per the act() reference they are not shared with LLM providers. Second, any AI method accepts a page option, so Playwright owns navigation while Stagehand handles the messy middle.
The observe() bridge is the pattern the determinism skeptics should like most. It returns concrete Action objects you can inspect, log, and replay:
tests/checkout.ts (continued)
const [candidate] = await stagehand.observe("find the checkout button");
// { selector: "/html/body/...", description, method: "click", arguments: [] }
if (candidate) await stagehand.act(candidate); // deterministic replay, no inferenceOne inference to discover the action, zero to execute it. Persist the Action and you have a selector-based script that regenerates itself when it breaks.
Caching is the determinism bridge
This is where "is Stagehand deterministic" gets a real answer. Stagehand 3.6 has two cache layers: a server cache on Browserbase (on by default with env: "BROWSERBASE", disable with serverCache: false) and a local filesystem cache enabled by setting cacheDir, which works in both environments.
The cache key is built from the instruction text (exact wording matters), the page URL with query params filtered, the page content and accessibility tree, the selector scope, and the variable keys, not values. That last part is why variables exist: interpolate a dynamic value into the instruction string and you bust the cache on every run; pass it as a variable and one cache entry covers all values.
The things that silently invalidate your cache, straight from the docs:
- A significant page-structure change (the point, but also the surprise when a marketing banner ships)
- A URL change
- A different viewport size
- Runtime environment drift: user agent, locale, third-party scripts
The docs' own determinism recipes follow directly: lock the viewport with page.setViewportSize({ width: 1280, height: 720 }), block noisy asset requests with page.route(), anchor instructions to stable UI text, and use variables for anything dynamic.
The payoff numbers, from the deterministic agent docs: a first agent run takes roughly 20 to 30 seconds and around 50,000 tokens; the cached rerun takes 2 to 3 seconds and zero tokens. Browserbase headlines it as 10 to 100x faster on reruns. Those are vendor figures, but they match the mechanism: a cache hit replays recorded actions without touching a model. The recommended setup is one cacheDir per workflow (cache/login-test, cache/checkout-test) with selfHeal: true (the default) as the fallback when a cached test breaks.
So: act(), extract(), and observe() are as deterministic as your caching discipline makes them. agent() on a cache miss is not deterministic, period. Design accordingly.
The honest cost math
Most comparisons print per-action dollar figures. The ones we checked trace back to low-authority listicles with no primary source, so we will not repeat them. Here is what is actually verifiable:
- A Playwright selector step costs $0 in inference and takes milliseconds.
- An
act()cache miss costs one LLM inference over a prompt that includes the page's accessibility tree. The cost-optimization docs recommend against premium models for simple tasks and point togoogle/gemini-2.5-flashas the cheap option. - An
act()cache hit costs zero tokens. - An
agent()first run is around 50,000 tokens per the docs; the cached rerun is zero. - Browser time on Browserbase: the Developer plan is $20/month for 100 browser hours, then $0.12/hour; Startup is $99/month for 500 hours, then $0.10/hour. LLM inference is billed pay-as-you-go through their Model Gateway at market rates, which is all the pricing page commits to.
Rather than trust anyone's per-action estimate, including ours, measure your own tests. In v3, metrics is async:
tests/report-costs.ts
const m = await stagehand.metrics;
console.log(m.totalPromptTokens, m.totalCompletionTokens);Run your test once cold and once warm, diff the token counts, multiply by your model's rates. That number is real. Everything else is a blog post.
Where Smoketest fits
We run exactly this hybrid in production: Smoketest monitors critical user tests in real browsers, with deterministic steps where the path is known and AI actions where the UI moves, cached so reruns cost nothing. The setup above works, and it is worth building yourself if tests are your product. If they are just how you find out signup broke, wiring viewport locks, cache directories, and token accounting for every test stops being a good use of your week.
FAQ
Is Stagehand deterministic?
act(), extract(), and observe() are as deterministic as you make them: with a cache hit, they replay recorded actions with zero LLM calls. On a miss, they run inference and can vary. agent() is not deterministic on first runs. Caching plus observe()-then-act(action) replay is the bridge, and Stagehand's docs treat it as the intended production pattern.
Does Stagehand still use Playwright?
No. Since v3 (October 2025), Stagehand talks to Chromium-based browsers directly over the Chrome DevTools Protocol; playwright-core is only an optional peer dependency. Comparisons describing it as "built on Playwright" are describing v2. Playwright remains fully compatible as an interop layer via connectOverCDP.
Can I use Playwright and Stagehand together?
Yes, and in production you probably should. Connect Playwright to Stagehand's browser with chromium.connectOverCDP({ wsEndpoint: stagehand.connectURL() }), then pass the Playwright page into any AI method via the page option. Playwright handles known paths at native speed; act() handles the volatile spots.
How much does an act() call cost?
No trustworthy per-action figure exists; the ones circulating trace to unsourced listicles. Verifiable anchors: a cache hit is zero tokens, a miss is one inference over an accessibility-tree prompt, and an agent first run is roughly 50,000 tokens per the docs. Measure your own tests with await stagehand.metrics.
When should I use agent() instead of act()?
Use agent() only for genuinely unstructured, multi-step tasks where you cannot enumerate the steps in advance, and cap it with maxSteps (default 20). For anything you can decompose, chained act() calls are cheaper, faster, and cacheable per step, which makes failures easier to localize.


