Playwright persistent contexts, and what they still do not give you
If your Playwright script has to stay logged in between runs, launchPersistentContext is the built-in answer. It is genuinely useful and it is where anyone should start. It is also only half of what an identity needs, and the half it does not cover is the half that gets accounts flagged.
What it does
import { chromium } from 'playwright'
const context = await chromium.launchPersistentContext('./user-data/account-01', {
headless: false,
})
const page = context.pages()[0] ?? await context.newPage()
await page.goto('https://example.com')
Instead of a fresh temporary profile, Chromium writes to the directory you name. Cookies, localStorage, IndexedDB, service workers and extension state survive the process. Run it again against the same path and you are still logged in.
Three API gotchas that catch people
It returns a BrowserContext, not a Browser. This is the one that breaks scripts.
const context = await chromium.launchPersistentContext(dir)
context.browser() // null. Not a bug.
await context.close() // correct
Any helper written against browser.newContext() needs rewriting. There is one context, and it is the thing you were handed.
It opens with a page already. A persistent context restores its session, so context.pages() is usually non-empty. Blindly calling newPage() leaves you with a stray blank tab.
Directory locking. One Chromium process per user-data directory. Launching the same profile twice gives you a cryptic failure, so if you run things in parallel, one directory per worker.
What it does not give you
A stable fingerprint. This is the gap that matters. The profile directory preserves browser state, not device identity. Canvas hash, WebGL renderer, font list, audio stack and hardware properties come from the machine and the Chromium build, not from the directory.
Run the same persistent profile on a different machine, or after a Chromium upgrade, and the site sees the same cookies arriving from different hardware. That combination is worse than a clean logout: a session token presented by a device that is not the device it was issued to.
And if you are running many identities on one machine, persistent contexts give you N sets of cookies with one identical fingerprint. Isolation of state, zero isolation of identity. Any competent risk engine clusters them immediately.
Proxy and locale binding. Playwright accepts a proxy option, but nothing makes the timezone, locale or geolocation follow the exit IP. You can set timezoneId and locale by hand, and you will eventually set one wrong, which is a contradiction cheaper to detect than the proxy itself.
A WebAuthn authenticator. Passkeys are bound to an authenticator, and a plain persistent context has none. It cannot enrol one and cannot replay one, so any account that moves to passwordless sign-in becomes unreachable. Playwright's CDP WebAuthn domain can add a virtual authenticator, but nothing persists the credential with the profile, so it dies with the context.
When persistent contexts are enough
- One identity, one machine, targets that do not fingerprint aggressively.
- Local development and testing where you are just tired of logging in.
- Anything short-lived where the account is disposable.
If that is you, stop here. You do not need another tool.
When they are not
Many identities on one machine, or an identity that must look like the same device across machines and Chromium upgrades, or accounts that have moved to passkeys.
That is what AntiBrow's profiles add on top: the fingerprint is generated once per profile and persisted alongside the cookies, applied in the browser engine so it is not an override script in the page; proxy binding derives timezone, locale and geolocation from the exit IP; and each profile carries its own virtual authenticator whose credentials live with the profile.
The API stays Playwright:
import { AntiDetectBrowser } from 'anti-detect-browser'
const ab = new AntiDetectBrowser({ key: 'your-api-key' })
const { browser, page } = await ab.launch({ profile: 'account-01' })
await page.goto('https://example.com')
Same selectors, same assertions, same traces. Only the launch line differs.
Test the difference in ten minutes
Run a persistent context, note the canvas hash and WebGL renderer, close it, reopen it. They should be identical, and they will be, on the same machine. Now run a second persistent context in a different directory and compare: identical again, which is the problem.
The free tier is unlimited local profiles with no card, so comparing costs nothing.
Try it on the free tier.
Unlimited local profiles, no credit card. Check it against the detectors yourself.