How to migrate from Playwright to Stagehand

TL;DR: Stagehand v4 drives Chromium over the Chrome DevTools Protocol and has no Playwright dependency, so migrating means porting a script, not wrapping one. Port it as-is first, add explicit waits where you relied on auto-waiting, then swap the selectors that break most often for self-healing act() and extract(). You keep the Playwright-style API you already know and gain natural-language steps that survive UI changes, plus server-side caching that removes inference once a flow is stable.

Playwright is a great automation library. It was built for testing, where the page is yours and the markup is stable. Agents run against a different web: third-party checkouts, A/B tested marketing pages, and dashboards that reshuffle their DOM between deploys. On that web, a selector-based script passes review on the happy path and starts flaking in production weeks later, at the layer you never tested.

Stagehand keeps the deterministic Playwright-style API you know and adds three natural-language primitives that re-resolve against the live page instead of a recorded selector. This guide ports a TypeScript Playwright script to Stagehand v4 end to end, then shows where to spend a model call and where a plain CSS selector is still the right tool.

Why migrate from Playwright to Stagehand?

Three reasons, in the order they tend to matter once a flow is in production: speed, resilience, and the infrastructure your agent runs on.

Performance

Stagehand runs as an extension next to the browser rather than over a remote connection, which closes the distance for every action on the page and cuts round-trip latency. On top of that, its hybrid accessibility-tree trimming hands the model only what it needs to understand the page, so agents spend fewer tokens per step.

The largest win is caching. Stagehand caches act(), observe(), and extract() results server-side, keyed on the instruction, page content, and options. On a cache hit the server returns the recorded action with no LLM inference and no token cost, so a stable flow stops paying for the model on every run. In practice that is up to 2x faster with roughly a 30% cost reduction on repeated actions.

Resilience

A Playwright selector encodes an assumption about the markup. When the site changes, the assumption breaks and the script throws. Stagehand's act(), extract(), and observe() take a natural-language instruction and resolve it against the page as it renders at call time, so a moved button or a renamed class no longer breaks the step. Turn on selfHeal and a recorded action re-infers itself when its cached selector stops resolving, then retries once. Stagehand also handles nested and out-of-process iframes and closed shadow DOM natively, cases where a selector-only approach usually needs special handling.

The Browserbase ecosystem

Point Stagehand at a Browserbase browser instead of a local one and the same script runs on managed cloud infrastructure: server-side caching, the Model Gateway that picks a model when you do not configure one, session recordings with frame-by-frame playback, live view, and network detail for debugging. It is the same Stagehand API, so the switch is one line at launch. Stagehand also ships experimental integrations for coding agents and the Vercel AI SDK, exposing one persistent browser to your agent as run, snapshot, and screenshot tools.

What actually changes?

Stagehand v4 has no Playwright interop. You cannot hand a Playwright Page to act(), so moving a flow means porting it rather than dropping Stagehand on top. The deterministic API is familiar but smaller: page.goto(), page.locator(), locator.fill(), and page.screenshot() behave the way you expect, but auto-waiting, getBy* locators, expect(), and route interception do not exist.

Stagehand is also not a test framework. Playwright is two things in one install, the library that automates a browser and the @playwright/test runner layered on top. Stagehand covers the first and has no counterpart to the second, so there are no fixtures, no expect(), no HTML reporter, and no trace viewer. Keep a general-purpose runner like Vitest or Jest and call Stagehand inside it.

PlaywrightStagehand v4
chromium.launch()localBrowser.launch() or browserbase.launch({ apiKey })
browser.newContext()One context per browser, at browser.context
context.newPage()browser.context.newPage(url?)
page.click(selector)page.locator(selector).click()
page.getByRole(), getByTestId()observe(), or page.locator() with a CSS selector
Auto-waiting on every actionpage.waitForSelector(), or a retry loop
expect(locator).toHaveText()Read innerText(), or extract() with a schema
page.route(), request mockingcontext.setDomainPolicy() blocks whole domains
@playwright/test, fixturesBring your own runner, such as Vitest or Jest

The full mapping table lives in the Stagehand docs. Source: docs.stagehand.dev/v4/migrations/playwright

What do you need before you start?

Install the SDK and set a Browserbase API key. Stagehand requires Node.js 22.18 or later and drives the Chrome you already have installed for local runs; a Browserbase run needs nothing installed at all. When no model is configured, the Model Gateway selects one, so no model-provider key is required to start.

pnpm add @browserbasehq/stagehand zod
export BROWSERBASE_API_KEY=your_api_key

Stagehand does not read environment variables on your behalf. Read the key in your own code and pass it explicitly to the browser factory.

How do you port the launch and teardown?

chromium.launch() becomes a browser factory, and Stagehand.create() attaches the runtime to what the factory returns. There is one context per browser, reached at browser.context, and newPage() takes an optional URL that saves a goto(). Close both handles: stagehand.close() releases the runtime and leaves the browser running, so call browser.close() yourself.

import { browserbase, Stagehand } from "@browserbasehq/stagehand";

const browser = await browserbase.launch({
  apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({ browser });

const page = await browser.context.newPage("https://example.com");
await page.locator("a").click();
console.log(await page.title());

await stagehand.close();
await browser.close();

One trap to know up front: page.click(), page.hover(), and page.type() changed meaning. They take coordinates or raw text now, not selectors, so route every selector through page.locator(). The compiler catches the mistake.

How do you handle selectors and getBy* locators?

page.locator() takes CSS, XPath, or text=, and CSS selectors pierce shadow DOM including closed roots. Use >> to hop into an iframe, which replaces frameLocator(). Keep every stable selector exactly as it is; a CSS selector costs no latency or tokens, so reach for it wherever the markup holds still.

The getBy* family has no direct equivalent. Rewrite getByTestId("submit") as page.locator('[data-testid=submit]') when the markup is stable. When the original describes the target by role or context rather than markup, ask a model: observe() returns candidate actions with real selectors attached, so you can drive one deterministically or hand it straight to act() to replay with no further inference.

const { data: actions } = await stagehand.observe("the add to cart button");
await page.locator(actions[0].selector).click();

How do you replace auto-waiting?

Waiting is where most ported scripts start flaking. Playwright retries every action until the element is attached, visible, and enabled; Stagehand resolves the selector once and throws if it is not there yet. Two defaults differ and are easy to miss. Playwright's goto() waits for load, while v4's waits for domcontentloaded, so spell out the state where you relied on subresources being ready. And nothing auto-waits, so wait first, then act.

await page.goto(url, { waitUntil: "load" }); // v4 defaults to domcontentloaded

await page.waitForSelector("#results", { state: "visible" });
await page.locator("#results").click();

A single wait is not the same as Playwright's per-action retry. Where a wait condition is hard to express as a selector, act() absorbs it: the model reads the page when the call runs, so it sees whatever has rendered by then.

What should you convert after the port?

A one-for-one port gets the script green on v4. Once it runs, convert the steps that break most often. Replace churny selectors on marketing pages, third-party checkouts, and A/B tested UI with act(), and turn on selfHeal so a recorded action re-infers when its selector breaks.

const stagehand = await Stagehand.create({ browser, selfHeal: true });

await stagehand.act("Click the 'new' link in the top navigation");

A count() and nth() scraping loop collapses into one extract() call with an array schema, typed and validated in a single round trip instead of one per row.

import { z } from "zod/v4";

const { data } = await stagehand.extract(
  "Extract the titles of the first five stories",
  z.object({ titles: z.array(z.string()) }),
);
console.log(data.titles);

Then turn on caching once a flow is stable. Set cache: true on Stagehand.create() and Browserbase caches every act(), observe(), and extract() call, returning hits instantly with no token cost. Read metadata.cache.status to confirm a hit.

const stagehand = await Stagehand.create({
  browser,
  cache: true,
});

Common errors when porting

Four failures account for most of the flakiness a fresh port hits. Each has a one-line fix.

  • Could not find an element for the given xPath. The selector resolved before the element rendered. Add page.waitForSelector(selector, { state: "visible" }) before acting, or wrap racy steps in a retry loop.
  • A selector passed to page.click() does nothing. page.click() now takes coordinates. Route selectors through page.locator(selector).click() instead.
  • A script that quietly acts on the wrong element. Strict mode is not enforced, so an ambiguous locator acts on the first match. Where the original relied on strict mode, assert count() === 1 first.
  • Subresources are not ready after goto(). v4 defaults to domcontentloaded. Pass { waitUntil: "load" } where the original relied on the full load event.

How do you take it to production?

The same script runs locally or on Browserbase; the difference is one line at launch. Swap localBrowser.launch() for browserbase.launch({ apiKey }) and the flow runs on managed cloud browsers with server-side caching, session recordings, live view, and network detail for debugging. To attach to an existing session instead of launching a new one, use browserbase.connect({ apiKey, sessionId }). Browserbase runs more than 35 million browser sessions a month and is SOC 2 Type II, so the same port that ran on your laptop scales without new code.

Frequently Asked Questions

Can I run Playwright and Stagehand side by side?

Not through interop. Stagehand v4 has no Playwright dependency and cannot accept a Playwright Page, so you port a flow rather than wrapping it. You can keep both in the same repo during a migration and move scripts over one at a time.

Do I have to rewrite every selector?

No. Keep every stable CSS or XPath selector as-is; page.locator() takes them directly and even pierces shadow DOM. Only the getBy* family and chaining helpers like filter() have no equivalent, and you replace the selectors that break most often with act() or extract() after the port is green.

Does Stagehand support Firefox or WebKit?

No. Stagehand is Chromium only and has no bundled browser download step. A local run uses the Chrome you already have installed, and a Browserbase run needs nothing installed at all.

What happens to my Playwright test assertions?

There is no expect() and no web-first assertion retry. Combine an explicit page.waitForSelector() with your runner's assertions, or use extract() with a schema to assert on typed data about what the page says.

Is Stagehand slower because it uses an LLM?

Only where you choose to. Deterministic page.locator() calls run with no model at all, act(), observe(), and extract() results cache server-side so repeated runs skip inference entirely, and Stagehand runs next to the browser to cut round-trip latency. Reach for a model call only on the steps that actually change.


Last updated: August 22, 2026. Based on Stagehand v4. See the full Playwright migration reference at docs.stagehand.dev/v4/migrations/playwright.