DEV Community

Cover image for Browser Tab Throttling for Playwright Agents: Why 50 Concurrent Sessions Trigger CAPTCHAs and How to Queue Them
mech.app
mech.app

Posted on Originally published at mech.app

Browser Tab Throttling for Playwright Agents: Why 50 Concurrent Sessions Trigger CAPTCHAs and How to Queue Them

Running 50 concurrent Playwright sessions inside an agent workflow sounds like a throughput win. In practice, it triggers CAPTCHA walls, eats CPU, and creates a retry death spiral that drags your entire pipeline down.

The fix is not better stealth headers or residential proxies. It is concurrency caps, overflow queuing, and bounded retries. A setup with 5 active sessions and 5 queued tasks often beats 50 tabs running wild.

The Self-Inflicted Load Problem

Browser automation agents fail in a predictable pattern:

  1. One worker hits a CAPTCHA or rate limit
  2. It retries immediately
  3. Ten more workers hit the same domain
  4. They all retry at once
  5. Old sessions hang, consuming memory and CPU
  6. The agent pipeline backs up behind stuck browser contexts
  7. Downstream LLM steps process junk or duplicates

This looks like a target-site problem in logs. Most of the time, it is your own concurrency model.

Why High Browser Concurrency Backfires

Every Playwright browser or context adds pressure:

  • CPU contention (rendering, JS execution, event loop)
  • Memory pressure (each context holds DOM state, network cache, cookies)
  • Bursty traffic to the same domain (looks bot-like)
  • Retry amplification (failures cascade across workers)

Two bad things happen at once: you look more suspicious to the target, and your own browser stack gets less stable.

Concurrency Cap and Queue Architecture

The working pattern is simple:

  • Cap active browser sessions (5 to 10 is a good starting point)
  • Queue overflow tasks in memory or Redis
  • Pace requests per domain (1 to 3 seconds between hits)
  • Bound retries (3 max, with exponential backoff)
  • Kill stuck sessions fast (30-second timeout)

In-Memory Queue (Node.js Example)

class BrowserPool {
  constructor(maxActive = 5, maxQueued = 10) {
    this.maxActive = maxActive;
    this.maxQueued = maxQueued;
    this.active = new Set();
    this.queue = [];
    this.domainLastHit = new Map();
  }

  async acquire(domain, task) {
    if (this.queue.length >= this.maxQueued) {
      throw new Error('Queue full');
    }

    // Wait for slot
    while (this.active.size >= this.maxActive) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }

    // Domain pacing
    const lastHit = this.domainLastHit.get(domain) || 0;
    const elapsed = Date.now() - lastHit;
    if (elapsed < 2000) {
      await new Promise(resolve => setTimeout(resolve, 2000 - elapsed));
    }

    const session = { id: crypto.randomUUID(), domain, task };
    this.active.add(session);
    this.domainLastHit.set(domain, Date.now());
    return session;
  }

  release(session) {
    this.active.delete(session);
  }

  async execute(domain, task, retries = 3) {
    let attempt = 0;
    while (attempt < retries) {
      const session = await this.acquire(domain, task);
      try {
        const result = await Promise.race([
          task(),
          new Promise((_, reject) => 
            setTimeout(() => reject(new Error('Timeout')), 30000)
          )
        ]);
        this.release(session);
        return result;
      } catch (err) {
        this.release(session);
        attempt++;
        if (attempt >= retries) throw err;
        await new Promise(resolve => 
          setTimeout(resolve, Math.pow(2, attempt) * 1000)
        );
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Redis-Backed Queue (Python with BullMQ-style pattern)

For distributed agents or multi-container deployments, Redis provides durable queuing:

import redis
import time
import uuid

class RedisBrowserQueue:
    def __init__(self, redis_url, max_active=5):
        self.redis = redis.from_url(redis_url)
        self.max_active = max_active
        self.active_key = "browser:active"
        self.queue_key = "browser:queue"
        self.domain_key_prefix = "browser:domain:"

    def enqueue(self, domain, task_data):
        queue_size = self.redis.llen(self.queue_key)
        if queue_size >= 50:
            raise Exception("Queue full")

        task = {
            "id": str(uuid.uuid4()),
            "domain": domain,
            "data": task_data,
            "enqueued_at": time.time()
        }
        self.redis.rpush(self.queue_key, json.dumps(task))
        return task["id"]

    def acquire(self):
        while self.redis.scard(self.active_key) >= self.max_active:
            time.sleep(0.1)

        task_json = self.redis.lpop(self.queue_key)
        if not task_json:
            return None

        task = json.loads(task_json)
        self.redis.sadd(self.active_key, task["id"])

        # Domain pacing
        domain_key = f"{self.domain_key_prefix}{task['domain']}"
        last_hit = self.redis.get(domain_key)
        if last_hit:
            elapsed = time.time() - float(last_hit)
            if elapsed < 2.0:
                time.sleep(2.0 - elapsed)

        self.redis.setex(domain_key, 10, time.time())
        return task

    def release(self, task_id):
        self.redis.srem(self.active_key, task_id)
Enter fullscreen mode Exit fullscreen mode

Trade-offs: In-Memory vs. Redis Queue

Dimension In-Memory Queue Redis Queue
Latency 1-5 ms 10-20 ms (network RTT)
Durability Lost on crash Survives restarts
Multi-worker Single process only Distributed agents
Observability Logs only Redis CLI, monitoring tools
Complexity 50 lines of code Redis dependency, serialization
Failure mode Process dies, queue lost Redis down, all agents stall

Session Affinity and Partial Workflow Resumption

If an agent needs to resume a partially completed browser workflow (e.g., multi-step form, OAuth flow), you need session affinity:

  • Store session state (cookies, localStorage, auth tokens) in Redis or a database
  • Tag tasks with a session_id
  • Route tasks with the same session_id to the same worker
  • Serialize Playwright context state using context.storageState()
// Save state
const state = await context.storageState();
await redis.set(`session:${sessionId}`, JSON.stringify(state));

// Restore state
const state = JSON.parse(await redis.get(`session:${sessionId}`));
const context = await browser.newContext({ storageState: state });
Enter fullscreen mode Exit fullscreen mode

This adds complexity but prevents re-authentication and session loss on retry.

Observability Hooks

You need visibility into queue depth, active sessions, and retry rates:

  • Emit metrics on enqueue, acquire, release, timeout, retry
  • Track per-domain hit rates and CAPTCHA rates
  • Log session lifecycle (created, active, released, killed)
  • Expose a /metrics endpoint for Prometheus or Datadog

Example metrics:

browser_pool_active_sessions 5
browser_pool_queued_tasks 3
browser_pool_retries_total{domain="example.com"} 12
browser_pool_captchas_total{domain="example.com"} 2
browser_pool_timeouts_total 1
Enter fullscreen mode Exit fullscreen mode

Likely Failure Modes

Queue fills up: Set a max queue depth and reject new tasks with a 429 or backpressure signal. Do not let the queue grow unbounded.

Redis goes down: In-memory fallback or circuit breaker. If Redis is unavailable, either queue in-process or reject tasks.

Stuck sessions leak: Timeout enforcement is critical. Use Promise.race() with a hard timeout (30 seconds) and kill the browser context on expiry.

Domain-specific rate limits: Track per-domain pacing separately. A global cap is not enough if one domain allows 10 req/s and another allows 1 req/min.

CAPTCHA solver latency: If you integrate a CAPTCHA solver (2Captcha, Anti-Captcha), add a separate timeout and retry budget. Solver failures should not block the queue.

Technical Verdict

Use this approach when:

  • Your agent opens more than 5 concurrent browser sessions
  • You hit CAPTCHAs or rate limits regularly
  • You run browser automation inside n8n, Make, Zapier, or custom orchestrators
  • You need distributed queuing across multiple agent workers

Avoid or defer when:

  • You run fewer than 3 concurrent sessions (overhead is not worth it)
  • Your target sites have no rate limiting or bot detection
  • You need sub-second latency (queuing adds 10-100 ms per task)
  • You are prototyping and do not care about stability yet

The boring fix (cap, queue, pace, timeout) beats clever stealth every time. Start with 5 active sessions, 5 queued, and 2-second domain pacing. Tune from there based on CAPTCHA rates and CPU usage.


Source Links

Top comments (0)