Migrating from Browserless
You have a Browserless deployment (cloud or self-hosted) and want to
scope a port to Driftstack without surprises. The core difference:
Browserless hands you a remote Chrome to script over CDP (the
Chrome DevTools Protocol — the wire protocol Puppeteer speaks);
Driftstack is action-based — you send typed steps (navigate, tap,
type, wait, extract) to an iPhone Safari session over HTTPS, and there is
no /function-style endpoint that runs your JavaScript server-side.
On hosted Driftstack the session to use is an
agent session: POST /v1/agent-sessions with the
proxy_id of one of your saved proxies, then your own steps with
POST /v1/agent-sessions/{id}/steps.
The step-by-step calls on /v1/sessions do not run on hosted today (see
Step-by-step operations on the hosted service).
Surface comparison
| Browserless | Driftstack |
|---|---|
Connect Puppeteer to wss://chrome.browserless.io?token=… |
POST /v1/agent-sessions with a proxy_id, then send steps (navigate, interact, wait, extract, capture) with POST /v1/agent-sessions/{id}/steps. No CDP passthrough. |
/function (POST a JS body, get JSON back) |
No direct equivalent. Express the flow as steps; reads go through an extract step, whose text comes back in the step’s summary. |
/screenshot |
A capture step with capture: "screenshot"; fetch the image by its captureId with GET /v1/agent-sessions/{id}/captures/{captureId}. |
| Concurrency you meter yourself | Tier-driven cap; going over returns a 429 with the stable concurrency-limit problem type — see Concurrency & backpressure. |
| Stealth flags / fingerprint plugins | Device profiles (archetype in the API — the device, OS and browser version a session presents) plus saved profiles that you reuse across sessions. |
The first port: replace the WebSocket
A typical Browserless integration is a Puppeteer script pointed at their WebSocket endpoint:
const browser = await puppeteer.connect({
browserWSEndpoint: `wss://chrome.browserless.io?token=${TOKEN}`,
});
const page = await browser.newPage();
await page.goto('https://example.com');
const heading = await page.$eval('h1', (el) => el.textContent);
await browser.close();
On Driftstack the same flow is an agent session and one list of steps:
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));
}
const run = await client.agentSessions.runSteps(
session.id,
[
{ kind: 'navigate', url: 'https://example.com' },
{ kind: 'extract', selector: 'h1' },
],
{ idempotencyKey: randomUUID() },
);
if (run.kind === 'plan-executed') {
const read = run.results.find((r) => r.intent.kind === 'extract');
console.log(read?.kind === 'success' ? read.summary : run.results); // the heading's text
}
} finally {
await client.agentSessions.close(session.id);
}
No server-side JS anywhere: a login + scrape is one list of steps ending
in an extract step. Steps run as written, with no AI planning and none
of the AI budget used.
Profile persistence: the differentiator
Browserless gives you a fresh browser per connection unless you manage your own cookie jar. Driftstack persists cookies, local storage, and IndexedDB server-side in a named profile:
const profile = await client.profiles.create({ name: 'evergreen-scraper' });
// Start a session on the profile, drive the login once, and close it:
// closing saves the profile's cookies and storage.
const session = await client.agentSessions.create(
{ mode: 'ai', proxy_id: process.env.DRIFTSTACK_PROXY_ID!, profile_id: profile.id },
{ idempotencyKey: randomUUID() },
);
try {
// … wait for `ready`, then send the login steps with runSteps …
} finally {
// Closing saves the signed-in cookies and storage to the profile.
await client.agentSessions.close(session.id);
}
Later sessions bind the same state by passing profile_id on
POST /v1/agent-sessions. A profile allows one live
session at a time — a second create against it returns
409 profile-in-use naming the active session, so two runs can’t
overwrite each other’s cookies. A profile snapshot is not a backup of
that signed-in state: it keeps the profile’s settings, and a profile
restored from one starts with no cookies, localStorage or IndexedDB.
Full flow: Profile management.
Picking a tier
Concurrency is the sizing axis: each plan runs a set number of sessions at once (the full cap table), and Enterprise is sized by contract. Every plan includes the API. Plans and prices: driftstack.io/pricing.
What Driftstack deliberately doesn’t do
- Raw WebSocket / CDP passthrough. The action endpoints are the contract; arbitrary protocol messages are blocked on purpose.
- Server-side JS execution. No
/functionequivalent. Express the flow as actions, or do the computation client-side over anextract/dom_snapshotread. - Browser choice. Driftstack is iPhone Safari (WebKit) by product scope — it is not a Chrome or Firefox automation service.
- Session video recordings. The API has none (the desktop app
can save recordings on your computer);
capturesteps are the API’s observation tool.
Self-hosting, on the other hand, does exist — as its own offering rather than a toggle on the cloud plans. See driftstack.io/self-hosted.
Migration checklist
- Inventory your Browserless calls — count
/functionvs/screenshotvs raw-CDP usage. - If
/functionbodies do non-trivial in-page computation, or raw CDP is a large share of your traffic, email [email protected] before starting — the action surface may not cover you yet, and it’s cheaper to learn that first. - Port one job with the TypeScript, Python, or Go SDK.
- Subscribe to webhooks for
session.challenge_detected(a bot check paused a session) andsession.profile_save_failed. An agent session sends nosession.completedorsession.failed; read itsstatusandclosed_reasonto learn it ended. - Check usage + quotas before flipping production traffic.
Need help porting?
Email [email protected] with
your /function bodies and call volumes; that’s enough to scope
whether the action surface covers the workload and which tier fits.