Migrating from Puppeteer / Playwright
Puppeteer and Playwright drive a browser process you run yourself. Driftstack runs the browser for you — iPhone Safari, built from Apple’s own WebKit and running on Macs — and you drive it over plain HTTPS calls. The surface is action-based: instead of executing your script inside the browser, you send discrete, typed steps (navigate, tap, type, wait, extract). This page maps the concepts and walks an incremental port.
You do not connect Puppeteer or Playwright to a Driftstack session: there
is no CDP or WebDriver endpoint to connect to. Your code sends the steps over
HTTPS instead. On hosted Driftstack those steps go to an
agent session: create one with the proxy_id of one
of your saved proxies, then send your steps with
POST /v1/agent-sessions/{id}/steps.
They run as written, with no AI planning and none of the AI budget used. The
step-by-step calls on /v1/sessions do not run on hosted today (see
Step-by-step operations on the hosted service).
Why teams migrate
- No browsers to host. No headless containers to keep alive, no grid to patch, no test machines to babysit.
- Apple’s WebKit, not Chrome. Each session is a real browser built on WebKit, the open-source engine behind Safari, running on Macs and set up so websites see iPhone Safari — not a desktop Chromium browser changed to report a phone, and not an emulator.
- Profile persistence is built in. Cookies, local storage, and IndexedDB live in a server-side profile: a session started on it loads them, and closing the session saves them back — replacing bespoke “save the cookie jar to disk” code.
- Concurrency is explicit. Tier caps plus a typed 429 give you a real backpressure signal instead of an overloaded machine.
Concept mapping
| Puppeteer / Playwright | Driftstack |
|---|---|
puppeteer.launch() / browser.newContext() |
POST /v1/agent-sessions with a proxy_id — a fresh session; pass profile_id to start from saved state. |
page.goto(url) |
A navigate step: { "kind": "navigate", "url": … } in POST /v1/agent-sessions/{id}/steps. |
page.click(sel) / page.type(sel, text) |
An interact step — action: tap, type, scroll or press. |
page.waitForSelector(...) |
A wait step — condition: selector_visible (with a CSS selector) or idle. |
page.$eval / page.$$eval |
An extract step — the text of one element, or one of its attributes, comes back in the step’s summary. |
page.evaluate(fn) |
Not exposed — arbitrary script execution is deliberately absent. Most uses are covered by extract + client-side post-processing. |
page.content() |
An extract step with body: true — the page’s rendered text. |
page.screenshot() |
A capture step with capture: "screenshot"; fetch the image by its captureId with GET /v1/agent-sessions/{id}/captures/{captureId}. |
page.url() / cookies |
GET /v1/agent-sessions/{id}/page-state and GET /v1/agent-sessions/{id}/cookies. |
browser.close() |
DELETE /v1/agent-sessions/{id} |
--user-data-dir=... |
Profiles — persistent named state a session binds to via profile_id. |
Saving / loading storageState.json |
The profile itself: a session started with profile_id loads its cookies and storage, and closing the session saves them. Snapshots copy a profile’s settings only, never its cookies or storage. |
| Test parallelism via shards / workers | Concurrent-session caps per tier; the concurrency-limit 429 is your backpressure signal. |
Side by side: a login + scrape
The Puppeteer original:
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://app.example.com/login');
await page.type('#email', '[email protected]');
await page.type('#password', process.env.SECRET);
await page.click('button[type=submit]');
await page.waitForSelector('#dashboard');
const kpi = await page.$eval('#kpi', (el) => el.textContent);
await browser.close();
The same flow on Driftstack:
import { randomUUID } from 'node:crypto';
import { Driftstack } from '@driftstack/sdk';
const client = new Driftstack({ apiKey: process.env.DRIFTSTACK_API_KEY! });
// Every hosted session goes out through one of your saved proxies.
const session = await client.agentSessions.create(
{ mode: 'ai', proxy_id: process.env.DRIFTSTACK_PROXY_ID! },
{ idempotencyKey: randomUUID() },
);
try {
// Wait up to two minutes for its browser: `status` is `active` from the start, so read `ready`.
const readyBy = Date.now() + 120_000;
while (Date.now() < readyBy) {
const s = await client.agentSessions.get(session.id);
if (s.status === 'closed') throw new Error('The session did not start: ' + s.closed_reason);
if (s.ready !== false) break;
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
// The password is held for this session only; the steps carry its placeholder.
const password = await client.agentSessions.registerSecret(session.id, {
label: 'App password',
secret: process.env.SECRET!,
sites: ['app.example.com'],
});
const run = await client.agentSessions.runSteps(
session.id,
[
{ kind: 'navigate', url: 'https://app.example.com/login' },
{ kind: 'interact', action: 'type', selector: '#email', value: '[email protected]' },
{
kind: 'interact',
action: 'type',
selector: '#password',
value: `{{credential:${password.handle}}}`,
},
{ kind: 'interact', action: 'tap', selector: 'button[type=submit]' },
{ kind: 'wait', condition: 'selector_visible', selector: '#dashboard', timeoutMs: 10_000 },
{ kind: 'extract', selector: '#kpi' },
],
{ idempotencyKey: randomUUID() },
);
if (run.kind === 'plan-executed') {
const kpi = run.results.find((r) => r.intent.kind === 'extract');
console.log(kpi?.kind === 'success' ? kpi.summary : run.results);
}
} finally {
await client.agentSessions.close(session.id);
}
Three things to note. The password is a
session secret: it is typed only on
app.example.com, and the steps, results and transcript carry its
placeholder, never the value. For a login your jobs repeat every day, save
the password once as a saved credential instead: a step
types it by its {{credential:cred_…}} placeholder, only on one of its
websites. And one list runs up to 8 steps; a step that cannot run stops the
list there, and its row in results says why.
What has no direct equivalent
- Arbitrary
page.evaluatescript execution. The surface is action-based on purpose. Structured reads areextract; whole-page reads are anextractstep withbody: true. If you have an extraction need neither covers, email [email protected] — don’t work around it by scraping screenshots. - Waiting for late-loading content with
networkidle. There is no step that matches it. (POST /v1/sessions/:id/navigateacceptswait_until: 'networkidle', and it currently waits for the load event, the same asload.) If your script relied on Puppeteer’snetworkidle0/networkidle2or Playwright’snetworkidleso that content added after the page loaded was in place before the next step, wait for that content directly: follownavigatewith awaitstep onselector_visiblefor the element you need. - CDP / DevTools protocol access. Driftstack does not expose a
DevTools protocol.
capturesteps are the observation tool. - Zero-latency chaining. Every list of steps is an HTTPS round trip.
Batch: one list runs up to 8 steps, so several
extractsteps in one list replace a series of$evals. - Browser extensions / stealth plugins. The managed browser doesn’t load extensions; fingerprint consistency comes from the device profile you pick (the device, iOS and Safari combination), not from plugins you ship.
An incremental migration path
- Wrap. Keep your existing test/scrape code and write a thin
compatibility layer that maps your
page.click/page.gotohelpers onto Driftstack calls. Port one flow at a time behind it. - Profiles second. Replace hand-rolled “save cookies, load them
next run” persistence with a profile:
start each run with
profile_id, and closing the session saves its cookies and storage. This is usually the first place the port deletes code. Do not use a snapshot for this: a snapshot keeps the profile’s settings, and a profile restored from one starts with no cookies,localStorageor IndexedDB. - Webhooks third. Subscribe to webhooks for
what they cover: a bot check pausing a session
(
session.challenge_detected) and a failed profile save (session.profile_save_failed). An agent session sends nosession.completedorsession.failed, so keep reading itsstatusandclosed_reasonto learn it ended. - Drop the wrapper. Once the last direct Puppeteer caller is gone, use the SDK surface directly.
Common gotchas
- Prefer stable selectors. A
waitstep waits for a CSS selector to show (selector_visible) or for the page to go quiet (idle). Putdata-test-*attributes on anything you own. - Don’t sleep client-side;
waitin the steps. A client-side sleep holds your concurrent slot while it lasts, and an agent session left for about 5 minutes without a step or input is closed; aselector_visiblewait returns the moment the element shows. - Close promptly. A session holds a concurrent slot until it
ends. One left with no step or input closes on its own after about
5 minutes, but do not wait for that — always close in a
finally(see session lifecycle). - Scope your automation keys narrowly. Driving an agent session
needs a key with
read+write, notaccount_owner. See API key scopes.
Where to go next
- Quickstart (curl) — see the raw HTTP calls end to end.
- Agent sessions reference — every step, field by field.
- Profile management — the user-data-dir replacement, in depth.
- Concurrency & backpressure — sizing parallel workloads.