Why parallel matters more than fast
Most browser automation guides focus on making a single browser faster — less latency, faster selectors, optimized wait strategies. That's the wrong optimization. When you have cloud browsers, the bottleneck isn't speed. It's concurrency.
Consider a real task: you need to check pricing on 50 competitor websites. With one browser, that's:
- 50 sites × 30 seconds per site = 25 minutes
- Sequential. One failure blocks the rest.
- One IP address. You look like a bot after site #10.
With 50 parallel browsers:
- 50 sites, all at once = 30 seconds total
- Each browser is independent. One failure doesn't affect the others.
- 50 different IP addresses. Each site sees one visit from one IP.
- Total cost: 50 × $0.004 (1 minute each) = $0.20
The speed improvement is 50x. But the real unlock is the architecture: each browser is stateless, isolated, and disposable. If a site blocks one browser, the other 49 keep working. If a browser crashes, you just launch another one. Failures are local, not global.
Companies like ZoomInfo, Clearbit, and SimilarWeb don't run one fast scraper. They run thousands of concurrent browsers across thousands of IPs. Cloud browser containers give you the same architecture without the infrastructure team.
The fan-out/fan-in pattern
The architecture is simple:
- Fan out: Launch N browser containers simultaneously. Each gets a URL and a task.
- Execute: Each browser navigates its URL, extracts data, and returns results.
- Fan in: Collect all results. Handle any that failed. Aggregate.
- Cleanup: Terminate all browsers.
In Python, this is asyncio.gather() or asyncio.TaskGroup(). In Node.js, it's Promise.all(). The pattern is language-agnostic — the key is launching containers concurrently and collecting results.
Full implementation: 50 competitors in 30 seconds
Let's build a real competitive intelligence scraper. It visits 50 competitor pricing pages, extracts plan names and prices, and compiles a comparison.
Setup
pip install httpx playwright playwright install chromium # Only for type stubs export SANDBOX_API_KEY="ab0t_sk_live_YOUR_KEY"
The parallel scraper
""" Fan out 50 cloud browsers to scrape 50 websites simultaneously. Each browser navigates to a URL, extracts pricing data, and returns results. Total cost: ~$0.20 for 50 browsers x 5 minutes each. """ import asyncio import json import time from dataclasses import dataclass, field from typing import Optional import httpx from playwright.async_api import async_playwright SANDBOX_URL = "https://sandbox.dev.ab0t.com" API_KEY = "ab0t_sk_live_YOUR_KEY" HEADERS = {"Authorization": f"Bearer {API_KEY}"} # Maximum concurrent browsers. Start with 10, scale up to 50+. MAX_CONCURRENT = 50 # Seconds to wait for container to become ready READY_TIMEOUT = 45 # Seconds for page navigation timeout PAGE_TIMEOUT = 30000 @dataclass class ScrapeResult: url: str success: bool = False data: dict = field(default_factory=dict) error: Optional[str] = None duration_ms: int = 0 container_id: Optional[str] = None async def launch_browser(http: httpx.AsyncClient) -> dict: """Launch a cloud browser and return connection details.""" resp = await http.post( f"{SANDBOX_URL}/api/browsers", headers=HEADERS, json={ "browser_type": "headless-shell", # Cheapest option for scraping "idle_timeout_minutes": 10, }, ) resp.raise_for_status() return resp.json() async def wait_ready(http: httpx.AsyncClient, container_id: str) -> bool: """Poll until container is ready. Returns True if ready, False if timeout.""" for _ in range(READY_TIMEOUT // 2): try: resp = await http.get( f"{SANDBOX_URL}/api/containers/{container_id}/status", headers=HEADERS, ) state = resp.json().get("state", "") if state in ("ready", "assigned"): return True except: pass await asyncio.sleep(2) return False async def terminate(http: httpx.AsyncClient, container_id: str) -> None: """Terminate a browser container.""" try: await http.delete( f"{SANDBOX_URL}/api/containers/{container_id}", headers=HEADERS, ) except: pass # Best-effort cleanup async def scrape_one( url: str, extraction_js: str, semaphore: asyncio.Semaphore, http: httpx.AsyncClient, pw: "AsyncPlaywrightContextManager", ) -> ScrapeResult: """Scrape a single URL using a dedicated cloud browser.""" result = ScrapeResult(url=url) start = time.monotonic() async with semaphore: container_id = None try: # 1. Launch a browser browser_info = await launch_browser(http) container_id = browser_info["container_id"] result.container_id = container_id cdp_url = browser_info["cdp_url"] # 2. Wait for ready ready = await wait_ready(http, container_id) if not ready: result.error = "Container did not become ready" return result # 3. Connect Playwright browser = await pw.chromium.connect_over_cdp(cdp_url) context = browser.contexts[0] page = context.pages[0] if context.pages else await context.new_page() # 4. Navigate await page.goto(url, timeout=PAGE_TIMEOUT, wait_until="domcontentloaded") await page.wait_for_load_state("networkidle") # 5. Extract data data = await page.evaluate(extraction_js) result.data = data if isinstance(data, dict) else {"raw": data} result.success = True await browser.close() except Exception as e: result.error = str(e)[:200] finally: # 6. Always terminate the browser if container_id: await terminate(http, container_id) result.duration_ms = int((time.monotonic() - start) * 1000) return result async def scrape_many( urls: list[str], extraction_js: str, max_concurrent: int = MAX_CONCURRENT, ) -> list[ScrapeResult]: """Scrape multiple URLs in parallel using cloud browsers.""" semaphore = asyncio.Semaphore(max_concurrent) async with httpx.AsyncClient(timeout=60) as http: async with async_playwright() as pw: tasks = [ scrape_one(url, extraction_js, semaphore, http, pw) for url in urls ] results = await asyncio.gather(*tasks) return list(results) # ======================================== # Example: Scrape 50 pricing pages # ======================================== PRICING_URLS = [ "https://vercel.com/pricing", "https://netlify.com/pricing", "https://render.com/pricing", "https://railway.app/pricing", "https://fly.io/pricing", "https://www.heroku.com/pricing", "https://www.digitalocean.com/pricing", "https://aws.amazon.com/pricing/", # ... add up to 50 URLs ] # JavaScript that runs inside each browser to extract pricing data EXTRACT_PRICING = """() => { const title = document.title; const h1 = document.querySelector('h1')?.textContent?.trim() || ''; // Try to find pricing cards/plans const plans = Array.from( document.querySelectorAll('[class*="price"], [class*="plan"], [class*="tier"]') ).slice(0, 10).map(el => ({ text: el.textContent?.trim()?.slice(0, 200), })); // Extract any dollar amounts on the page const priceRegex = /\\$[\\d,.]+(?:\\/\\w+)?/g; const allText = document.body.innerText; const prices = [...new Set(allText.match(priceRegex) || [])].slice(0, 20); return { title, heading: h1, prices, plan_count: plans.length, plans: plans.slice(0, 5), url: window.location.href, }; }""" async def main(): print(f"Scraping {len(PRICING_URLS)} pricing pages in parallel...") start = time.monotonic() results = await scrape_many(PRICING_URLS, EXTRACT_PRICING, max_concurrent=20) elapsed = time.monotonic() - start successes = [r for r in results if r.success] failures = [r for r in results if not r.success] print(f"\n{'='*60}") print(f"Completed in {elapsed:.1f}s") print(f" Success: {len(successes)}/{len(results)}") print(f" Failed: {len(failures)}/{len(results)}") print(f"{'='*60}\n") # Print results for r in successes: d = r.data prices_str = ", ".join(d.get("prices", [])[:5]) print(f" {d.get('title', '?'):50s} {prices_str}") if failures: print(f"\nFailed URLs:") for r in failures: print(f" {r.url}: {r.error}") # Save full results as JSON output = [{ "url": r.url, "success": r.success, "data": r.data, "error": r.error, "duration_ms": r.duration_ms, } for r in results] with open("pricing-comparison.json", "w") as f: json.dump(output, f, indent=2) print(f"\nFull results saved to pricing-comparison.json") # Cost estimate total_minutes = sum(r.duration_ms for r in results) / 60000 cost = total_minutes * (0.10 / 60) # browser containers at $0.1/hr print(f"Estimated cost: ${cost:.4f}") asyncio.run(main())
20 browser containers launch simultaneously (controlled by the semaphore). Each navigates to a different pricing page, runs the extraction JavaScript, and returns structured data. As containers finish and are terminated, the semaphore releases slots for the remaining URLs. Total wall-clock time: ~40 seconds. Total cost: ~$0.02.
Tuning concurrency
The max_concurrent parameter controls how many browsers run at the same time. Start low and scale up.
| Concurrency | Wall Clock (50 URLs) | Total Cost | When To Use |
|---|---|---|---|
| 1 (sequential) | ~25 min | $0.17 | Never. This is the old way. |
| 5 | ~5 min | $0.04 | Testing, gentle on target sites |
| 10 | ~2.5 min | $0.04 | Good default for most workloads |
| 20 | ~1.5 min | $0.04 | Production scraping, diverse targets |
| 50 | ~40 sec | $0.04 | Maximum speed, 50 different sites |
| 100 | ~40 sec | $0.08 | Hitting 100 sites. Check rate limits. |
If you're scraping the same domain 50 times (e.g., 50 pages on Amazon), the bottleneck is the target site's rate limit, not your browser count. Spread requests across different domains, or add delays between requests to the same domain. 50 browsers across 50 different sites is the sweet spot.
Concurrency with rate limiting per domain
from collections import defaultdict from urllib.parse import urlparse # Per-domain semaphores: max 3 concurrent requests to the same domain domain_semaphores: dict[str, asyncio.Semaphore] = defaultdict( lambda: asyncio.Semaphore(3) ) async def scrape_with_domain_limit(url: str, ...) -> ScrapeResult: domain = urlparse(url).netloc async with domain_semaphores[domain]: return await scrape_one(url, ...) # Now even if you fan out 100 URLs, no single domain gets # more than 3 concurrent requests. Different domains run # at full concurrency.
Handling failures at scale
When you run 50 browsers, some will fail. Sites go down, pages change, JavaScript errors crash extraction. At scale, you need resilient patterns.
Pattern 1: Retry with backoff
async def scrape_with_retry( url: str, extraction_js: str, max_retries: int = 3, **kwargs, ) -> ScrapeResult: """Retry failed scrapes with exponential backoff.""" for attempt in range(max_retries): result = await scrape_one(url, extraction_js, **kwargs) if result.success: return result # Don't retry on 404 or known-bad URLs if result.error and ("404" in result.error or "403" in result.error): return result # Exponential backoff: 2s, 4s, 8s await asyncio.sleep(2 ** attempt) print(f" Retry {attempt + 1}/{max_retries}: {url}") return result # Return last failure
Pattern 2: Partial success is fine
# At scale, expect some failures. Design for partial success. results = await scrape_many(urls, extraction_js) successes = [r for r in results if r.success] failures = [r for r in results if not r.success] print(f"Success rate: {len(successes)}/{len(results)} ({len(successes)*100//len(results)}%)") # A 90% success rate across 50 URLs is normal. Sites go down, # page structures change, JavaScript errors happen. Don't fail # the entire run because 5 out of 50 URLs had issues. # Optionally retry just the failures if failures: retry_results = await scrape_many( [r.url for r in failures], extraction_js, max_concurrent=5, # Gentler on retry ) # Merge retry successes into main results
Pattern 3: Screenshot on failure
# In scrape_one, before the except block: except Exception as e: # Take a screenshot for debugging try: await page.screenshot(path=f"errors/{container_id}.png") except: pass result.error = str(e)[:200] # Now you have a visual record of what the page looked like # when the extraction failed. Was it a CAPTCHA? A different layout? # A JavaScript error popup? The screenshot tells you instantly.
Real use cases at scale
The parallel scraping pattern applies far beyond pricing pages. Here are the workloads where 50+ browsers make sense:
Competitive intelligence
Monitor 50 competitor websites daily. Detect pricing changes, new feature announcements, blog posts, job listings (which signal product direction). Each browser visits one competitor, extracts key data, compares to yesterday's snapshot. Run it on a cron schedule.
Lead enrichment
You have 1,000 company URLs from a CRM export. For each, visit their website and extract: company size, industry, tech stack (from script tags and meta tags), contact page email, social media links. Fan out 50 browsers at a time, process all 1,000 in under 10 minutes.
SEO monitoring
Check your rankings across 100 keywords on Google. Each browser searches a different keyword, scrolls through results, finds your position. 100 browsers, 100 keywords, 2 minutes, $0.07.
Real estate listings
Scrape new listings from 30 real estate portals simultaneously. Each browser handles one portal (different login flow, different DOM structure, different pagination). Compare listings across portals to find exclusive deals.
Visual regression testing
Take screenshots of 200 pages on your website across 4 browsers (Chrome, Firefox, mobile Chrome, tablet). 800 screenshots total. Fan out across cloud browsers, compare to baseline images, flag regressions. Run after every deployment.
Academic research
An AI research agent needs to read 100 papers. Each browser navigates to a paper, extracts the abstract and key findings, downloads the PDF. The agent compiles a literature review from the extracted data. 100 papers in 5 minutes instead of days.
Cost at scale
| Scenario | Browsers | Duration Each | Browser Type | Total Cost |
|---|---|---|---|---|
| 50 pricing pages | 50 | ~1 min | headless-shell | $0.02 |
| 1,000 company profiles | 50 (batched) | ~2 min each | headless-shell | $0.67 |
| Daily competitor check (20 sites) | 20 | ~3 min | chrome | $0.04/day |
| Visual regression (200 pages x 4 browsers) | 50 (batched) | ~30 sec | chrome | $0.27 |
| Academic paper extraction (100 papers) | 50 | ~2 min | headless-shell | $0.07 |
Most parallel browser workloads cost less than $1 per run. The daily competitor check is $0.04/day — $1.20/month. That's less than a Slack subscription. The economics of cloud browsers mean the compute cost is never the bottleneck. The LLM costs for the AI agent (if you use one) will be 10-100x higher than the browser compute.
Optimization: warm pools for instant start
Cold-starting 50 containers takes 15-30 seconds each. If you're running this on a schedule (daily competitive intel), you can eliminate startup time with warm pools.
Warm pools keep N browser containers pre-provisioned and ready. When you request a browser, you get one from the pool in under 1 second. After you're done, the container is flushed (cookies cleared, state reset) and returned to the pool for the next user.
Without warm pools: 50 browsers x 20 sec startup = 50 sec before first data With warm pools (20 warm): First 20 browsers: instant (<1 sec) Next 30: 20 sec startup (overlapped with first 20 working) Net time: ~25 sec total vs ~50 sec Cost of warm pool: 20 idle browsers x $0.1/hr = ~$2.00/hr Worth it if you run 10+ times per hour. Not worth it for once-a-day jobs.
See the warm pools guide for full setup instructions.
Production tips
Start with max_concurrent=10, then scale up
10 concurrent browsers is safe for any target. Test your extraction logic works, check for rate limiting, then increase to 20, 50, 100.
Use headless-shell for pure data extraction
It's 50% cheaper and starts faster. Only use chrome when you need screenshots or visual debugging.
Implement per-domain rate limiting
Even with 50 browsers, you shouldn't hit the same domain with all of them simultaneously. Use the per-domain semaphore pattern shown above.
Save extraction JavaScript in separate files
Don't inline your extraction logic. Keep it in .js files per site. This makes it easy to update when a site changes its DOM structure.
Log container IDs for debugging
When a scrape fails, the container ID tells you which browser it was. If you saved error screenshots, you can find the screenshot by container ID.
Add monitoring for recurring jobs
Track success rates over time. If a site's success rate drops from 95% to 60%, its DOM structure probably changed. Alert on this so you can update the extraction JavaScript.
Consider a headless browser for API-heavy SPAs
Many modern sites fetch all their data via API calls. Instead of extracting from the DOM, intercept the API responses (Pattern 4 from the Playwright guide). The structured JSON from the API is more reliable than scraping the rendered HTML.
Troubleshooting
Many containers fail to start
You may be hitting container concurrency limits. Reduce max_concurrent to 20. If you need sustained high concurrency, contact us to pre-allocate capacity in your region.
Extraction returns empty data
The page probably renders content dynamically via JavaScript. Add await page.wait_for_selector(".target-element") before running extraction. Or increase the wait_for_timeout to let JavaScript load.
All 50 get blocked by the same site
You're hitting rate limits. Use per-domain semaphores (max 2-3 concurrent per domain). Add random delays between 1-3 seconds. Consider rotating user agents.
Memory errors on complex pages
Default containers have 2 GB memory. If you're scraping heavy SPAs (dashboards, data-heavy apps), request more: "memory": "4096".
Results are inconsistent
Sites render differently based on location, logged-in state, and A/B tests. Each container has a different IP, which means different geo-targeting. Normalize your extraction logic to handle variation.
What's next
You've learned to fan out browser containers at scale. The next guides build on this with AI-powered decision-making:
Launch 50 browsers in parallel
$0.20 for fifty simultaneous scrapes. Isolated containers, different IPs, independent failures. This is what browser automation looks like at scale.
Get Started Free