Async Playwright + Antidetect Browser: Python CDP Guide (2026)
Last Tuesday I spent four hours debugging a scraping job that kept getting blocked on the third page of results. Proxies were fine. User agents rotated. Headers looked normal. The problem? Canvas fingerprint. Every single request came from a browser with identical canvas hashes — because Playwright's default Chromium doesn't randomize anything. Might as well have stamped "BOT" on every request.
Switching to antidetect browser profiles fixed it in about 20 minutes. And since I was already neck-deep in an async Python pipeline, I needed Playwright's async API talking to JustBrowser's REST API without blocking my entire event loop.
This tutorial walks through that exact setup. By the end, you'll have async Python code that launches antidetect 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 — the 7-day trial gives you unlimited profiles, which is more than enough to follow along
- Basic familiarity with async/await in Python
If you've never touched async Python, spend 15 minutes on the asyncio docs first. I'll explain the antidetect-specific parts, but I'm assuming 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 a fingerprint preset that matches your target geography (US desktop, German laptop, whatever)
- Assign a proxy if your targets check IP reputation — HTTP/HTTPS/SOCKS5 all work
- Set timezone and language to match the proxy location
Fingerprints matter more than you'd think. Canvas, WebGL, audio context, font enumeration — sites check all of these. (I learned this the hard way after burning through a week of good proxies.) A native antidetect browser spoofs them at the Chromium engine level, not through JS patches that detection tools spot instantly. We covered why that distinction matters in the Playwright/Puppeteer stealth guide.
Note each profile's ID. You'll find it in the profile list or when you right-click the profile. Mine are UUIDs like a3f8c9d2-1b4e-5678-abcd-ef0123456789. Write these down.
Step 2: Understand the REST API Flow
JustBrowser exposes a REST API on localhost (default port 9222). The flow for automation:
- POST /api/profiles//launch — starts the browser with that profile's fingerprint
- Response includes a
wsEndpoint— the CDP WebSocket URL - Connect Playwright to that WebSocket
- Run your automation
- POST /api/profiles//stop — cleanly shuts down the browser
That's it. Browser runs as a separate process with native fingerprint protection. Playwright just controls it over CDP like any other remote browser. Simple. Deceptively simple, actually — I expected more ceremony.
Step 3: The Async Python Code
Here's the full working example. I'll break it down after.
import asyncio
from playwright.async_api import async_playwright
import httpx
API_BASE = "http://localhost:9222/api"
async def launch_profile(client: httpx.AsyncClient, profile_id: str) -> str:
"""Launch a JustBrowser profile and return its CDP WebSocket URL."""
resp = await client.post(
f"{API_BASE}/profiles/{profile_id}/launch",
json={"headless": False}
)
resp.raise_for_status()
data = resp.json()
return data["wsEndpoint"]
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")
# 200 = stopped, 404 = wasn't running — both are fine
if resp.status_code not in (200, 404):
resp.raise_for_status()
async def run_automation(ws_endpoint: str) -> dict:
"""Connect to the browser and do something useful."""
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp(ws_endpoint)
context = browser.contexts[0] # Profile's default context
page = await context.new_page()
await page.goto("https://abrahamjuliot.github.io/creepjs/")
await page.wait_for_timeout(3000) # Let fingerprint tests run
# Grab the trust score as proof it worked
score_el = await page.query_selector(".visitor-info .grade")
score = await score_el.text_content() if score_el else "N/A"
title = await page.title()
await browser.close()
return {"title": title, "trust_score": score}
async def process_profile(profile_id: str) -> dict:
"""Full lifecycle: launch, automate, stop."""
async with httpx.AsyncClient(timeout=30.0) as client:
ws_endpoint = await launch_profile(client, profile_id)
try:
result = await run_automation(ws_endpoint)
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",
"b4g9d0e3-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['trust_score']}")
if __name__ == "__main__":
asyncio.run(main())
Run it with python your_script.py. You should see two browser windows open (one per profile), visit CreepJS, and close. The console prints each profile's trust score.
Step 4: What Each Part Does
Let's break this down.
launch_profile() — POSTs to the JustBrowser API with headless: False. Headed mode is less detectable than headless, even with antidetect browsers. The response JSON includes wsEndpoint, something like ws://localhost:9223/devtools/browser/abc123. That's the CDP connection point.
stop_profile() — Cleans up. Always call this. Always. Orphaned browser processes eat RAM and will haunt you when you try to launch the same profile later. The 404 handling is intentional — if the browser crashed or was closed manually, we don't want to raise an exception.
run_automation() — This is where your actual scraping/testing/automation lives. connect_over_cdp() attaches Playwright to the already-running browser. We grab the existing context (the one with the profile's cookies and fingerprint) rather than creating a new one.
The CreepJS visit is just 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 launch and run in parallel. This is where async shines — a sync version would take 10x longer. (I say this having written the sync version first, like an idiot.)
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 — API returns the existing wsEndpoint, not an error. This is actually fine.
- Profile doesn't exist — 404. Check your profile IDs.
- JustBrowser not running — Connection refused. Start the app.
- Timeout on launch — Increase the httpx timeout. Profile launch takes 2-5 seconds, sometimes longer with cold starts.
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. Tried it. My machine became a space heater that couldn't render a cursor. Each Chromium instance uses 300-500MB. 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 warm instead of launching/stopping constantly. Launch at startup, run multiple tasks through each profile, stop at shutdown. Session cookies persist across tasks, which is usually what you want. Sometimes it's not — logged-in sessions can trip rate limiters faster. Your call.
Headed vs headless. Headed mode is less detectable but requires a display. On a headless Windows or macOS host, keep a desktop session available. 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. Check if the API is on a different port in settings. Update API_BASE accordingly.
httpx.ReadTimeout when launching profiles
Cause: Profile launch is slow, especially with complex fingerprints or 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: 'wsEndpoint' in launch response
Cause: API response structure changed, or you're hitting the wrong endpoint.
Fix: Print the full response: print(resp.json()). Check you're using the current API format. JustBrowser's API docs live at http://localhost:9222/docs when the app is running.
Next Steps
Now that you've got async Playwright talking to antidetect profiles, a few directions to explore:
Add proxy rotation. Each profile can have its own proxy. For scraping jobs where you cycle through targets, rotate profiles (and their attached proxies) rather than swapping proxies on a single profile. This maintains fingerprint-to-IP consistency, which matters for sites that track returning visitors. Most people get this backwards. Don't be most people.
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.
Test your fingerprints. Before running against real targets, hit CreepJS, FingerprintJS, and BrowserLeaks. The CreepJS walkthrough explains what those scores mean and what to fix if something's red.
Cross-product integration. If you're scraping 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 if you're running outbound alongside your data collection, JustEmails keeps deliverability high.
For more on the REST API capabilities — headless launch flags, profile management, bulk creation — check the API docs at http://localhost:9222/docs when JustBrowser is running. The JS/Node version of this tutorial covers some patterns that translate directly to Python if you want more examples.
And honestly? Start with 2-3 profiles on real targets before scaling to 50. You'll catch fingerprint issues, proxy problems, and rate limiting early. Better to debug at small scale than to burn 50 profiles finding out your canvas hash is still leaking.
I've made that exact mistake. Twice.
Frequently Asked Questions
Why use async Playwright instead of sync for antidetect 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?
JustBrowser returns the existing CDP WebSocket endpoint instead of launching a second instance. This is intentional — you can't have two browser processes for the same profile. If you need to force a fresh launch, stop the profile first via the /stop endpoint, then launch 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 antidetect browser — not extension-based. Real C++ engine patches at the canvas / WebGL / font / TLS layer, so 40+ identity parameters are genuine, not faked. REST API 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
Related Posts
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.