Async Playwright + Multi-Account Browser: Python CDP Guide (2026)
Automation that runs in a throwaway Playwright Chromium has no persistent identity: no saved logins, no per-client proxy, no stable settings. For QA, ad verification and your own recurring workflows, it is more useful to run the automation inside a multi-account browser profile that keeps its own cookies, storage, proxy and identity parameters. In an async Python pipeline, Playwright's async API needs to talk to JustBrowser's REST API without blocking the entire event loop.
This tutorial walks through that exact setup. By the end, you'll have async Python code that starts profiles, connects via CDP, runs your automation, and cleans up — all without blocking.
Prerequisites
You'll need:
- Python 3.10+ with asyncio (comes standard)
- Playwright for Python installed:
pip install playwright && playwright install chromium - httpx for async HTTP calls:
pip install httpx - JustBrowser installed and running (Windows x64 or macOS Apple Silicon) — the 7-day trial gives you unlimited profiles and the REST API, which is more than enough to follow along
- An API token from the AI page in the app
- Basic familiarity with async/await in Python
If you've never touched async Python, spend 15 minutes on the asyncio docs first. We'll explain the JustBrowser-specific parts, but this assumes you know what await does.
Step 1: Create Profiles in JustBrowser
Before we write any Python, set up a few profiles in JustBrowser's GUI.
Open JustBrowser and create 2-3 profiles. For each one:
- Pick an identity preset that matches the region you need to test (US desktop, German laptop, whatever)
- Assign a proxy if the workflow needs a specific region — HTTP/HTTPS/SOCKS5 all work
- Set timezone and language to match the proxy location
Consistency matters more than you'd think. Canvas, WebGL, audio context, font enumeration — pages can read all of these. A native multi-account browser sets them at the Chromium engine level, not through JavaScript patches, so they agree everywhere in the page. We covered why that distinction matters in the Playwright/Puppeteer guide.
Note each profile's ID. You'll find it in the profile list or when you right-click the profile. IDs look like a3f8c9d2-1b4e-5678-abcd-ef0123456789. Write them down.
Step 2: Understand the REST API Flow
JustBrowser exposes a local REST API, bound to 127.0.0.1:36542 by default (the port is configurable in the app). Every request needs a Bearer token from the AI page in the app. The flow for automation:
POST /api/v1/profiles/{profile_id}/start— starts the browser with that profile's settings. It starts headless unless the body says{"headless": false}.- The response includes
data.cdp_url— the CDP WebSocket URL - Connect Playwright to that WebSocket
- Run your automation
POST /api/v1/profiles/{profile_id}/stop— shuts the browser down. (browser.close()from Playwright only disconnects; the profile keeps running until you stop it.)
That's it. The browser runs as a separate process with its own cookies, storage, proxy and identity settings. Playwright just controls it over CDP like any other remote browser.
Step 3: The Async Python Code
Here's the full working example, broken down afterwards.
import asyncio
import os
from playwright.async_api import async_playwright
import httpx
API_BASE = "http://127.0.0.1:36542/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['JUSTBROWSER_TOKEN']}"}
async def start_profile(client: httpx.AsyncClient, profile_id: str) -> str:
"""Start a JustBrowser profile and return its CDP WebSocket URL."""
resp = await client.post(
f"{API_BASE}/profiles/{profile_id}/start",
json={"headless": False},
headers=HEADERS,
)
resp.raise_for_status()
return resp.json()["data"]["cdp_url"]
async def stop_profile(client: httpx.AsyncClient, profile_id: str) -> None:
"""Stop a running profile."""
resp = await client.post(f"{API_BASE}/profiles/{profile_id}/stop", headers=HEADERS)
# Errors come back as {"error": {"code", "message"}} - branch on the code
if resp.status_code >= 400:
print(f"stop {profile_id}: {resp.json().get('error', {}).get('code')}")
async def run_automation(cdp_url: str) -> dict:
"""Connect to the browser and do something useful."""
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp(cdp_url)
context = browser.contexts[0] # The profile's own context
page = context.pages[0] if context.pages else await context.new_page()
await page.goto("https://example.com/")
await page.wait_for_load_state("networkidle")
title = await page.title()
await browser.close() # disconnects only; the profile keeps running
return {"title": title}
async def process_profile(profile_id: str) -> dict:
"""Full lifecycle: start, automate, stop."""
async with httpx.AsyncClient(timeout=30.0) as client:
cdp_url = await start_profile(client, profile_id)
try:
result = await run_automation(cdp_url)
result["profile_id"] = profile_id
return result
finally:
await stop_profile(client, profile_id)
async def main():
# Replace with your actual profile IDs
profile_ids = [
"a3f8c9d2-1b4e-5678-abcd-ef0123456789",
"b4f9d0e3-2c5f-6789-bcde-f01234567890",
]
# Run all profiles concurrently
tasks = [process_profile(pid) for pid in profile_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
print(f"Error: {r}")
else:
print(f"Profile {r['profile_id']}: {r['title']}")
if __name__ == "__main__":
asyncio.run(main())
Run it with python your_script.py. You should see two browser windows open (one per profile), load the page, and close. The console prints each profile's page title.
Step 4: What Each Part Does
Let's break this down.
start_profile() — POSTs to the JustBrowser API with headless: False, so you get a visible window. A headless session has no real window, so its screen and viewport follow headless defaults. The response JSON includes data.cdp_url, something like ws://127.0.0.1:PORT/devtools/browser/<guid>. That's the CDP connection point.
stop_profile() — Cleans up. Always call this. Orphaned browser processes eat RAM and will cause trouble when you try to start the same profile later. Errors are enveloped, so branch on error.code rather than the message.
run_automation() — This is where your actual testing or automation lives. connect_over_cdp() attaches Playwright to the already-running browser. We use the existing context (the one with the profile's cookies and settings) rather than creating a new one, because new_context() would create an incognito context outside the profile.
The page visit is just a proof of concept. Replace it with whatever you actually need to do.
process_profile() — Wraps the full lifecycle. The try/finally ensures we stop the profile even if automation fails. Leaking browser processes is annoying at 2 profiles. It's catastrophic at 50.
main() — asyncio.gather() runs all profiles concurrently. If you have 10 profiles, all 10 start and run in parallel. This is where async shines — a sync version would take 10x longer.
Step 5: Handling Errors Gracefully
The example above uses return_exceptions=True so one failed profile doesn't crash everything. But you'll want better error handling in production.
async def process_profile_safe(profile_id: str) -> dict:
"""Wrapper with explicit error handling."""
try:
return await process_profile(profile_id)
except httpx.HTTPStatusError as e:
return {"profile_id": profile_id, "error": f"API error: {e.response.status_code}"}
except Exception as e:
return {"profile_id": profile_id, "error": str(e)}
Common failure modes:
- Profile already running — starting it again won't give you a second instance; stop it first if you need a fresh session.
- Profile doesn't exist — 404. Check your profile IDs.
- JustBrowser not running — Connection refused. Start the app.
- Timeout on launch — Increase the httpx timeout. A profile launch can take several seconds, longer on a cold start or behind a slow proxy.
Step 6: Scaling Up
Running 2 profiles is easy. Running 50? That's where things get interesting.
Concurrency limits. Don't launch 50 profiles simultaneously on a laptop with 16GB RAM. Each browser instance can use several hundred megabytes. Set a semaphore:
async def main():
sem = asyncio.Semaphore(10) # Max 10 concurrent profiles
async def limited_process(pid):
async with sem:
return await process_profile(pid)
tasks = [limited_process(pid) for pid in profile_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
Profile pools. For long-running jobs, keep profiles running instead of starting and stopping constantly. Start at the beginning, run multiple tasks through each profile, stop at the end. Session cookies persist across tasks, which is usually what you want; sometimes it's not, so start a fresh profile for a clean state. Your call.
Headed vs headless. Headed mode gives the profile a real window and a normal screen and viewport, but requires a display. JustBrowser runs on Windows and macOS only, so keep a desktop session available on the host. The Playwright/Puppeteer guide covers this in detail.
Common Errors and Fixes
ConnectionRefusedError: [Errno 61] Connection refused
Cause: JustBrowser isn't running, or the API port is different from default.
Fix: Start JustBrowser. The API is bound to 127.0.0.1:36542 by default; check the port in settings and update API_BASE accordingly.
httpx.ReadTimeout when launching profiles
Cause: Profile start is slow, especially with slow proxies.
Fix: Increase the timeout: httpx.AsyncClient(timeout=60.0). Or investigate why your proxies are slow.
playwright._impl._errors.TargetClosedError
Cause: Browser closed unexpectedly — crash, manual close, or another process stopped it.
Fix: Wrap automation in try/except. Consider a retry mechanism for transient failures.
KeyError: 'data' in the start response
Cause: The request returned an error envelope, or you're hitting the wrong endpoint.
Fix: Print the full response: print(resp.json()) and branch on error.code. The API reference is at justbrowser.app/docs.
Next Steps
Now that you've got async Playwright talking to JustBrowser profiles, a few directions to explore:
Keep proxies attached to profiles. Each profile can have its own proxy. For jobs that cycle through regions, switch between profiles (and their attached proxies) rather than swapping proxies on a single profile, so each profile's timezone, language and IP region stay consistent.
Integrate with your pipeline. If you're using something like Airflow, Prefect, or plain asyncio queues, slot this in as a task. The async interface plays well with most modern Python orchestrators.
Inspect your profiles. Before running real workflows, open a public inspector such as BrowserLeaks and confirm that each profile's timezone, language and IP region agree. The TLS fingerprint explainer covers the network layer that page inspectors can't show.
Cross-product integration. If you're collecting ad data, pair this with ClickzProtect for click fraud analysis. If you're collecting conversion metrics, JustAnalytics handles privacy-first tracking without relying on fingerprinting your own users. And for the email side of client work, JustEmails handles custom domains and mailboxes.
For more on the REST API capabilities — headless start options, profile management, bulk creation — check the API docs at justbrowser.app/docs. The JS/Node version of this tutorial covers some patterns that translate directly to Python if you want more examples.
Start with 2-3 profiles on your real workflow before scaling to 50. You'll catch proxy problems and rate limiting early, and it is better to debug at small scale.
Frequently Asked Questions
Why use async Playwright instead of sync for profile automation?
Async Playwright handles concurrent profile connections without blocking. If you're running 10 profiles simultaneously, sync Playwright forces you to wait for each browser action sequentially. Async lets you launch all 10, run their tasks in parallel, and collect results together — cutting total runtime dramatically. For data pipelines that touch dozens of profiles, async isn't optional.
Can I use aiohttp instead of httpx for the REST API calls?
Yes. Both work fine. httpx has a cleaner async context manager pattern and better HTTP/2 support, but aiohttp is equally capable. The code examples use httpx because it mirrors the requests API most Python devs already know. Swap in aiohttp if that's your preference — the JustBrowser REST API doesn't care which client hits it.
What happens if a profile is already running when I try to launch it?
A profile runs as one browser process, so you can't have two instances of the same profile at once. If you need a fresh session, stop the profile first via the /stop endpoint, then start it again.
How do I handle profiles across multiple machines in a distributed setup?
Each machine runs its own JustBrowser instance with its own profile set. Your orchestrator tracks which machine owns which profiles. When assigning work, route tasks to the machine that has the target profile. Cloud sync (unlimited) keeps profile data consistent if you need to move profiles between machines, but you can't run the same profile on two machines simultaneously.
Try JustBrowser
Native Chromium multi-account browser for teams — not extension-based. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, across 40+ identity parameters. Local REST API + CDP for Playwright, Puppeteer, Selenium. 7-day free trial, card required — then $9.99/month or $99.99/year, unlimited profiles, free team seats.
Get started → · How it differs from Multilogin / GoLogin / AdsPower
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.