Guide #7 Browser Agents 30 min read March 2026

50 Browsers in Parallel

One browser scraping one website is a script. Fifty browsers scraping fifty websites simultaneously is infrastructure. This guide shows you how to fan out browser containers at scale, collect results, handle failures, and do it all for $0.20.

This is the "holy shit" moment. The guide where the economics of cloud browsers click. When each browser costs $0.004 for 5 minutes of work, the question stops being "how many can I afford?" and becomes "how many do I need?" The answer, for most AI agent workloads, is a lot more than you think.

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:

With 50 parallel browsers:

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.

This is how professional scraping operations work

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:

  1. Fan out: Launch N browser containers simultaneously. Each gets a URL and a task.
  2. Execute: Each browser navigates its URL, extracts data, and returns results.
  3. Fan in: Collect all results. Handle any that failed. Aggregate.
  4. Cleanup: Terminate all browsers.
Orchestrator
fan out
Browser 1 Browser 2 ... Browser 50
fan in
Results
Each browser is independent. Each has its own IP, cookies, and session.

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

bash
pip install httpx playwright
playwright install chromium  # Only for type stubs

export SANDBOX_API_KEY="ab0t_sk_live_YOUR_KEY"

The parallel scraper

python — parallel_scrape.py
"""
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())
What happens when you run this

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.

ConcurrencyWall Clock (50 URLs)Total CostWhen To Use
1 (sequential)~25 min$0.17Never. This is the old way.
5~5 min$0.04Testing, gentle on target sites
10~2.5 min$0.04Good default for most workloads
20~1.5 min$0.04Production scraping, diverse targets
50~40 sec$0.04Maximum speed, 50 different sites
100~40 sec$0.08Hitting 100 sites. Check rate limits.
More browsers doesn't always mean more speed

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

python
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

python
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

python
# 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

python
# 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

ScenarioBrowsersDuration EachBrowser TypeTotal Cost
50 pricing pages50~1 minheadless-shell$0.02
1,000 company profiles50 (batched)~2 min eachheadless-shell$0.67
Daily competitor check (20 sites)20~3 minchrome$0.04/day
Visual regression (200 pages x 4 browsers)50 (batched)~30 secchrome$0.27
Academic paper extraction (100 papers)50~2 minheadless-shell$0.07
The cost reality

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.

text
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