Use this when one browser is too slow for the batch: crawls, screenshot sweeps, per-account checks. Each worker owns one session, so cookies and identity never leak between jobs, and a crashed page only loses its own URL.
Executable starters
parallel-sessions.ts
import { chromium } from 'playwright';const apiKey = process.env.BROWSERCITY_API_KEY;if (!apiKey) { throw new Error('Set BROWSERCITY_API_KEY before running this script.');}const urls = [ 'https://example.com', 'https://example.org', 'https://example.net',];const CONCURRENCY = 3; // Stay within your plan's parallel session allowance.async function processUrl(url: string): Promise<{ url: string; title: string }> { const { endpoint, token, id } = await fetch('https://api.browser.city/v1/sessions', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ browser: 'chromium' }), }).then((r) => r.json()); try { const browser = await chromium.connect(endpoint, { headers: { Authorization: `Bearer ${token}` }, }); const page = browser.contexts().at(0)!.pages().at(0)!; await page.goto(url); return { url, title: (await page.title()) ?? '' }; } finally { await fetch(`https://api.browser.city/v1/sessions/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); }}const queue = [...urls];const workers = Array.from({ length: CONCURRENCY }, async () => { const results: { url: string; title: string }[] = []; for (let url = queue.shift(); url; url = queue.shift()) { results.push(await processUrl(url)); } return results;});const results = (await Promise.all(workers)).flat();console.log(JSON.stringify(results, null, 2));import jsonimport osfrom concurrent.futures import ThreadPoolExecutorimport requestsfrom playwright.sync_api import sync_playwrightAPI_KEY = os.environ["BROWSERCITY_API_KEY"]AUTH = {"Authorization": f"Bearer {API_KEY}"}URLS = ["https://example.com", "https://example.org", "https://example.net"]CONCURRENCY = 3 # Stay within your plan's parallel session allowance.def process_url(url): session = requests.post( "https://api.browser.city/v1/sessions", headers=AUTH, json={"browser": "chromium"} ).json() try: with sync_playwright() as playwright: browser = playwright.chromium.connect( session["endpoint"], headers={"Authorization": f"Bearer {session['token']}"}, ) page = browser.contexts[0].pages[0] page.goto(url) return {"url": url, "title": page.title()} finally: requests.delete( f"https://api.browser.city/v1/sessions/{session['id']}", headers=AUTH )with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool: results = list(pool.map(process_url, URLS))print(json.dumps(results, indent=2))
Production hardening checklist
- Size the worker pool to your plan’s concurrency, not to the batch size.
- Always close sessions in a
finallyblock; leaked sessions bill until they time out. - Record per-URL failures and retry them in a second pass instead of aborting the batch.
- Label sessions per batch so usage views show which job consumed what.
Cost and plan notes
Total compute equals the sum of session minutes regardless of parallelism, so raising concurrency buys speed, not savings. If a batch step is pure extraction, consider the Request API instead — it is cheaper than holding open sessions.