Parallel Browser Agents: From Sequential Scripts to Concurrent Sessions

Hands On
Jun 8
by Idan Raman
Parallel Browser Agents: From Sequential Scripts to Concurrent Sessions

When you're prototyping a browser agent, one session is enough. But shipping to production almost always means volume: dozens of pages to scrape, hundreds of accounts to monitor, thousands of records to process.

If your agent handles tasks one at a time, the math gets ugly fast. A 30-second task run sequentially over 100 items takes nearly an hour. Run them concurrently and you're done in under a minute.

The Sequential Bottleneck

Most browser agents start with a simple loop:

from anchor_browser import AnchorClient

client = AnchorClient()
results = []

for url in urls:
    session = client.sessions.create()
    try:
        result = extract_data(session, url)
        results.append(result)
    finally:
        session.close()

This works. It's also exactly the pattern that breaks at scale — not just because it's slow, but because the architectural assumption (one task at a time) doesn't match real-world workloads.

The fix isn't just concurrency for speed. Each Anchor session has its own browser context, cookies, and fingerprint. Running tasks in parallel means complete isolation between jobs — no shared state, no cookie leaks between accounts, no fingerprint correlation.

Async Session Pool with asyncio

Anchor's Python SDK is async-native. Here's the core parallel pattern:

import asyncio
from anchor_browser import AsyncAnchorClient

client = AsyncAnchorClient()

async def process_url(semaphore: asyncio.Semaphore, url: str):
    async with semaphore:
        session = await client.sessions.create()
        try:
            return await extract_data(session, url)
        finally:
            await session.close()

async def run_batch(urls: list[str], max_concurrent: int = 10):
    semaphore = asyncio.Semaphore(max_concurrent)
    tasks = [process_url(semaphore, url) for url in urls]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    successful = [r for r in results if not isinstance(r, Exception)]
    failed = [r for r in results if isinstance(r, Exception)]
    print(f"Done: {len(successful)}/{len(urls)} succeeded, {len(failed)} failed")
    return successful

urls = [f"https://example.com/product/{i}" for i in range(50)]
results = asyncio.run(run_batch(urls, max_concurrent=10))

The asyncio.Semaphore is the critical piece. It caps how many sessions are open simultaneously regardless of how many tasks you queue. Set it too high and you risk rate-limiting; set it too low and you leave performance on the table.

Staggering Session Start Times

For some targets, launching 10 sessions at exactly the same millisecond looks suspicious. A small stagger fixes this without meaningfully slowing things down:

async def run_staggered(urls: list[str], max_concurrent: int = 10, stagger_ms: int = 200):
    semaphore = asyncio.Semaphore(max_concurrent)

    async def delayed_process(i: int, url: str):
        await asyncio.sleep(i * stagger_ms / 1000)
        return await process_url(semaphore, url)

    tasks = [delayed_process(i, url) for i, url in enumerate(urls)]
    return await asyncio.gather(*tasks, return_exceptions=True)

With stagger_ms=200 and max_concurrent=10, the first 10 tasks start across 2 seconds — natural enough for most bot-detection systems to treat as organic traffic.

Retry Logic for Transient Failures

At scale, some sessions will fail — target pages go down, bot detection triggers, timeouts fire. The return_exceptions=True flag prevents one failure from killing the entire batch. Add exponential backoff for transient errors:

async def process_with_retry(semaphore: asyncio.Semaphore, url: str, max_retries: int = 3):
    last_error = None
    for attempt in range(max_retries):
        try:
            return await process_url(semaphore, url)
        except Exception as e:
            last_error = e
            if attempt < max_retries - 1:
                await asyncio.sleep(2 ** attempt)  # 1s, 2s, 4s
    raise last_error

Production-Ready Wrapper

Here's a complete pattern that ties everything together:

import asyncio
import logging
from dataclasses import dataclass
from typing import Any
from anchor_browser import AsyncAnchorClient

@dataclass
class TaskResult:
    url: str
    data: Any = None
    error: str | None = None

    @property
    def ok(self) -> bool:
        return self.error is None

async def run_agent_batch(
    urls: list[str],
    max_concurrent: int = 10,
    stagger_ms: int = 150,
    max_retries: int = 3,
) -> list[TaskResult]:
    client = AsyncAnchorClient()
    semaphore = asyncio.Semaphore(max_concurrent)

    async def run_one(i: int, url: str) -> TaskResult:
        await asyncio.sleep(i * stagger_ms / 1000)
        async with semaphore:
            for attempt in range(max_retries):
                session = await client.sessions.create()
                try:
                    data = await extract_data(session, url)
                    await session.close()
                    return TaskResult(url=url, data=data)
                except Exception as e:
                    await session.close()
                    if attempt == max_retries - 1:
                        logging.warning(f"Failed after {max_retries} attempts: {url} — {e}")
                        return TaskResult(url=url, error=str(e))
                    await asyncio.sleep(2 ** attempt)

    tasks = [run_one(i, url) for i, url in enumerate(urls)]
    return await asyncio.gather(*tasks)


# Usage
async def main():
    urls = [f"https://shop.example.com/product/{sku}" for sku in range(1, 101)]
    results = await run_agent_batch(urls, max_concurrent=15, stagger_ms=100)

    succeeded = [r for r in results if r.ok]
    failed = [r for r in results if not r.ok]
    print(f"Processed {len(urls)} URLs: {len(succeeded)} OK, {len(failed)} errors")
    for r in failed:
        print(f"  ✗ {r.url}: {r.error}")

asyncio.run(main())

Tuning Concurrency for Your Workload

A few rules of thumb for setting max_concurrent:

  • Task duration matters: Short tasks (<5s) benefit from high concurrency (15–25). Long tasks (>30s) need fewer slots to keep memory and timeout management clean.
  • Target site tolerance: Most sites handle 5–10 concurrent requests from distinct fingerprints without triggering rate limits. Beyond that, staggering becomes more important than reducing concurrency.
  • Leave headroom for retries: If your plan allows 20 concurrent sessions, set max_concurrent to 16–17 so retried tasks can acquire slots without waiting behind new ones.

What's Next

Parallel sessions are the foundation. Once you have this pattern solid, the natural next step is adding observability — knowing which tasks are running, which failed, and why. Anchor's session replay and tracing features make that straightforward to layer on top of exactly this architecture.

Start with 5–10 concurrent sessions, measure your throughput and error rates, then tune from there. The bottleneck is almost never the session pool — it's usually the target site or your own parsing logic.

Get started with the Anchor Python SDK →

Stay ahead in browser automation

We respect your inbox. Privacy policy

Welcome aboard! Thanks for signing up
Oops! Something went wrong while submitting the form.