HomeLearn → Puppeteer & Playwright

Puppeteer & Playwright with isolated profiles

The no-code builder covers most automation. When you need real code — custom parsing, your own data structures, integration with your systems — you can attach directly to a running profile and keep everything that makes it a separate identity.

On this page

  1. The mistake worth avoiding first
  2. How attaching works
  3. Puppeteer
  4. Playwright
  5. Closing without killing the profile
  6. Running across many profiles
  7. Practical notes

The mistake worth avoiding first

The instinct is puppeteer.launch() with a proxy argument. Don't. That starts a fresh Chromium with a stock fingerprint, no cookies and none of the profile's identity — you get automation, but every script run looks like a brand-new anonymous machine, which is the opposite of what a profile is for.

Launching is not attaching

launch() starts your browser. connect() attaches to one that is already running with its fingerprint, proxy, cookies and storage in place. For per-profile work you always want the second.

How attaching works

Parallel runs a local HTTP API on 127.0.0.1:3777, gated by a token. You ask it to start a profile; it launches that profile exactly as clicking Start would, and hands back a Chrome DevTools Protocol WebSocket endpoint. Anything that speaks CDP can attach to it.

The token is in the app under Automation & API. Send it as Authorization: Bearer <token> or X-API-Token: <token>. The server binds to loopback only, so nothing outside your machine can reach it.

POST /profiles/:id/start   → { id, status, webSocketDebuggerUrl }
POST /profiles/:id/stop    → { id, status }
GET  /profiles/:id/status  → { id, running, webSocketDebuggerUrl }
GET  /profiles             → [ { id, name, status, running } ]

Add ?headless=true to start without a visible window.

Puppeteer

import puppeteer from 'puppeteer'

const API = 'http://127.0.0.1:3777'
const TOKEN = process.env.PARALLEL_TOKEN
const PROFILE_ID = process.env.PROFILE_ID

const res = await fetch(`${API}/profiles/${PROFILE_ID}/start`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${TOKEN}` }
})
if (!res.ok) throw new Error(`start failed: HTTP ${res.status}`)
const { webSocketDebuggerUrl } = await res.json()

// connect, NOT launch — this browser already has the profile's identity.
const browser = await puppeteer.connect({ browserWSEndpoint: webSocketDebuggerUrl })

const [page] = await browser.pages()
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
console.log(await page.title())

// Detach and leave the profile running, or stop it — see below.
await browser.disconnect()

browser.pages() returns the tabs already open in that profile. Take the first rather than calling newPage() if you want the tab the user would see.

Playwright

Playwright attaches over CDP with connectOverCDP. The profile is a persistent context, so it arrives as the first entry of contexts() — creating a new context would give you a blank one without the profile's cookies.

import { chromium } from 'playwright'

const browser = await chromium.connectOverCDP(webSocketDebuggerUrl)

// The profile's existing context — not a fresh one.
const context = browser.contexts()[0]
const page = context.pages()[0] ?? await context.newPage()

await page.goto('https://example.com')
console.log(await page.title())

await browser.close()  // detaches the CDP connection; see the note below

Playwright's close() is a disconnect here

When connected over CDP rather than launched by Playwright, browser.close() ends your connection — it does not shut down a browser Playwright did not start. Stop the profile through the API when you actually want it closed.

Closing without killing the profile

Three different things, worth keeping straight:

CallEffect
browser.disconnect() (Puppeteer)Detaches your script. The profile keeps running.
browser.close() (Puppeteer)Closes the browser itself — ends the profile session, which is usually not what you meant.
POST /profiles/:id/stopStops the profile cleanly, the same as pressing Stop in the app. This is the one to use.
try {
  // …your automation…
} finally {
  await browser.disconnect()
  await fetch(`${API}/profiles/${PROFILE_ID}/stop`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${TOKEN}` }
  })
}

Put the stop in a finally. A script that throws halfway and leaves the browser running is how you end up with fifteen orphaned Chromium processes.

Running across many profiles

const list = await (await fetch(`${API}/profiles`, {
  headers: { Authorization: `Bearer ${TOKEN}` }
})).json()

for (const profile of list) {
  try {
    await runOne(profile.id)
  } catch (err) {
    // One profile failing must not take the rest with it.
    console.error(`${profile.name}: ${err.message}`)
  }
}

Sequentially, or in small batches. Each profile is a real Chromium instance with real memory cost, and forty identical requests leaving at the same instant is its own correlation signal — see browser automation for why staggering matters.

Practical notes

Automate without losing the identity

Every Parallel profile keeps its own fingerprint, proxy and storage whether you drive it by hand, with the visual builder, or from your own code. Three profiles free.

Download for Windows