Home → Learn → 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
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:
| Call | Effect |
|---|---|
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/stop | Stops 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
- Starting an already-running profile is fine — you get the same endpoint back.
- A frozen profile will not start. If the account is over its plan limit, the API answers
402rather than launching it. - The endpoint changes on every launch. Never cache a
webSocketDebuggerUrlbetween runs; ask for it each time. - Keep the token out of your repo. Read it from the environment, as above.
- Headless is not just a hidden window.
?headless=trueis cheaper and fine for fetching data, but pages behave differently without one — lazy content may never load, and smooth scrolling does nothing. - The proxy and fingerprint are already applied — do not set a proxy or user agent in your script, or you will contradict the profile and undo the isolation.
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