Drıftstack docs
Docs

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.evaluate script execution. The surface is action-based on purpose. Structured reads are extract; whole-page reads are an extract step with body: 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/navigate accepts wait_until: 'networkidle', and it currently waits for the load event, the same as load.) If your script relied on Puppeteer’s networkidle0 / networkidle2 or Playwright’s networkidle so that content added after the page loaded was in place before the next step, wait for that content directly: follow navigate with a wait step on selector_visible for the element you need.
  • CDP / DevTools protocol access. Driftstack does not expose a DevTools protocol. capture steps 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 extract steps 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

  1. Wrap. Keep your existing test/scrape code and write a thin compatibility layer that maps your page.click / page.goto helpers onto Driftstack calls. Port one flow at a time behind it.
  2. 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, localStorage or IndexedDB.
  3. 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 no session.completed or session.failed, so keep reading its status and closed_reason to learn it ended.
  4. Drop the wrapper. Once the last direct Puppeteer caller is gone, use the SDK surface directly.

Common gotchas

  • Prefer stable selectors. A wait step waits for a CSS selector to show (selector_visible) or for the page to go quiet (idle). Put data-test-* attributes on anything you own.
  • Don’t sleep client-side; wait in 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; a selector_visible wait 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, not account_owner. See API key scopes.

Where to go next