Scaling Headless Tests with Browserless: Concurrency and CI

Scaling Headless Tests with Browserless: Concurrency and CI

Running 5 headless tests sequentially is easy. Running 50 in parallel across a CI pipeline without Chrome crashing, eating all memory, or fighting over ports — that's where the architecture decisions matter. Here's how Browserless handles scale and how to integrate it properly into CI.

Session Concurrency Model

Browserless manages browser sessions as a queue:

Incoming request
      │
      ▼
Is concurrent count < CONCURRENT?
   Yes → Start session immediately
   No  → Is queue count < QUEUED?
            Yes → Add to queue, wait
            No  → Return 429 Too Many Requests

Two environment variables control this entirely:

  • CONCURRENT — sessions running right now
  • QUEUED — sessions waiting to run

When a running session finishes, the next queued session starts. When the queue is full, clients get a 429 with a Retry-After header.

Calculating Your Limits

Each Chrome session uses roughly 150–400MB depending on page complexity. A test that opens a React SPA with lots of components can easily hit 400MB. A simple static page might be 150MB.

For a machine with 8GB RAM reserved for Browserless:

Available RAM: 8192 MB
Per-session budget: 300 MB (conservative estimate)
Max concurrent: 8192 / 300 ≈ 27

Set CONCURRENT=20 (leave headroom for the Browserless process itself and peak usage)
Set QUEUED=40 (allows burst traffic to queue rather than fail immediately)

CPU scales differently. Chrome is CPU-intensive during page rendering and JS execution but idles once a page is loaded. For a 4-core machine, 10–15 concurrent sessions is a reasonable starting point for test workloads.

What Happens When the Queue Fills

The 429 response body from Browserless:

{
  "message": "Too many requests",
  "retryAfter": 5000
}

Your Puppeteer/Playwright client needs to handle this. The WebSocket upgrade fails with a non-101 status code, which surfaces as a connection error. See the error handling sections in the Puppeteer and Playwright integration posts for retry logic.

For CI, the simpler fix is to match --workers to CONCURRENT so you never hit the limit:

# 10 concurrent Browserless sessions → 10 Playwright workers
npx playwright test --workers=10

GitHub Actions Integration

Hosted Browserless (Simplest)

name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci
      
      - name: Run Playwright tests
        env:
          BROWSERLESS_WS_ENDPOINT: ${{ secrets.BROWSERLESS_WS_ENDPOINT }}
        run: npx playwright test --workers=5
      
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Set BROWSERLESS_WS_ENDPOINT in GitHub Secrets as wss://chrome.browserless.io?token=YOUR_TOKEN.

No Chrome installation step. No --no-sandbox flags. No apt-get. The job starts faster.

Self-Hosted Browserless as a Service Container

If you're running your own Browserless, you can run it as a service container within the same GitHub Actions job:

name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    services:
      browserless:
        image: ghcr.io/browserless/chromium:2.18.0
        ports:
          - 3000:3000
        env:
          TOKEN: test-token
          CONCURRENT: 6
          QUEUED: 12
          TIMEOUT: 60000
        options: >-
          --shm-size=2g
          --health-cmd="curl -f http://localhost:3000/health"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci
      
      - name: Wait for Browserless
        run: |
          until curl -sf http://localhost:3000/health; do
            echo "Waiting for Browserless..."
            sleep 2
          done
      
      - name: Run tests
        env:
          BROWSERLESS_WS_ENDPOINT: ws://localhost:3000?token=test-token
        run: npx playwright test --workers=6
      
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-report
          path: playwright-report/

This runs Browserless in the same network namespace as your test job, so the connection is localhost — minimal latency, no egress charges.

Parallelizing Across Matrix

For large test suites, split across multiple runners using a matrix:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    
    services:
      browserless:
        image: ghcr.io/browserless/chromium:2.18.0
        ports:
          - 3000:3000
        env:
          TOKEN: test-token
          CONCURRENT: 4
        options: --shm-size=2g
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      
      - name: Run shard
        env:
          BROWSERLESS_WS_ENDPOINT: ws://localhost:3000?token=test-token
        run: npx playwright test --shard=${{ matrix.shard }}/4 --workers=4

4 runners × 4 workers = 16 parallel test executions, each runner with its own Browserless instance. Total test time approaches total_test_duration / 16.

Cost and Resource Comparison

Self-Hosted Browserless

Costs:

  • Compute: whatever you pay for the host (EC2, GKE node, bare metal)
  • No per-session fees
  • Maintenance: updates, monitoring, on-call

When it pays off:

  • High test volume (thousands of sessions per day)
  • Data residency requirements
  • Already running Kubernetes or docker infrastructure
  • Need custom Chrome extensions or specific Chrome versions

Resource estimate for a small team running 500 sessions/day:

  • A t3.large on AWS (2 vCPU, 8GB RAM): ~$60/month
  • Handles ~10 concurrent sessions comfortably
  • With queuing, 500 sessions/day is trivial even with a 5-second average session time

Hosted Browserless (browserless.io)

Costs:

  • Monthly subscription based on concurrent sessions
  • No infrastructure maintenance
  • Scales on demand

When it pays off:

  • Low-to-medium volume
  • You don't have existing infrastructure
  • Team prefers not to maintain browser infrastructure
  • Need to start quickly

The hidden cost of self-hosting: someone owns the maintenance. Browserless updates, Chrome security patches, monitoring alerts at 2am when the service OOMs. If your team is small and infrastructure isn't your core competency, the hosted price may be cheaper than the engineering time.

Running Chrome Directly in CI vs Browserless

Factor Chrome in CI Browserless in CI
Setup per job Install Chrome + dependencies (~30s–2min) Pull Docker image or use hosted (0s if cached)
Memory usage on runner All on the CI runner Offloaded to Browserless (service container or hosted)
Concurrency Limited by runner RAM Controlled by Browserless config
Consistency Chrome version tied to runner image Chrome version locked to Browserless image
Failure mode Chrome crashes take down the test process Connection errors — retryable
Sandbox issues Requires --no-sandbox in most CI environments Not needed — Browserless handles it
Parallel test isolation Harder — shared filesystem, port conflicts Better — each session is isolated

The main reason to still run Chrome directly in CI: simplicity. If you have a small test suite (<20 tests), adding Browserless is overhead that doesn't pay off. For anything larger, the consistency and concurrency benefits are real.

Monitoring Self-Hosted Browserless

Metrics Endpoint

Browserless exposes a /metrics endpoint compatible with Prometheus:

GET /metrics

Key metrics to watch:

  • browserless_sessions_active — current concurrent sessions
  • browserless_sessions_queued — sessions waiting
  • browserless_sessions_rejected — 429s returned (queue overflow)
  • browserless_session_duration_seconds — histogram of session lengths

Simple Alerting

Without Prometheus, use the /pressure endpoint for a quick check:

curl http://localhost:3000/pressure
{
  "date": 1717200000000,
  "running": 3,
  "queued": 0,
  "recentlyRejected": 0,
  "isAvailable": true,
  "sessionTimes": [1234, 2341, 891]
}

recentlyRejected > 0 means your CONCURRENT + QUEUED limits are too low for your traffic.

Health Check in Production

#!/bin/bash
HEALTH=$(curl -sf http://localhost:3000/health)
if [ $? -ne 0 ]; then
  echo "CRITICAL: Browserless health check failed"
  exit 2
fi

REJECTED=$(curl -sf http://localhost:3000/pressure | jq '.recentlyRejected')
if [ "$REJECTED" -gt "0" ]; then
  echo "WARNING: Browserless rejecting sessions (recentlyRejected=$REJECTED)"
  exit 1
fi

echo "OK: Browserless healthy"

Session Timeout Tuning

Two timeout values interact:

  1. Browserless TIMEOUT: maximum time a session can exist on the server (default: 30 seconds)
  2. Client-side timeout: Puppeteer's setDefaultNavigationTimeout, Playwright's timeout in config

Rules:

  • Set client timeout < server timeout so you get meaningful client-side errors
  • Set server timeout to match your longest legitimate test duration + 20% buffer
  • Long-running sessions that exceed server timeout are forcibly killed and return a 408

For a test suite where the longest test is 45 seconds:

TIMEOUT=60000  # 60s on Browserless
Client timeout: 50000ms  # 50s in Playwright/Puppeteer config

This way: if a test hangs, your test runner gets a clean timeout error. If Browserless kills it, the error is distinguishable (408 vs TimeoutError).

Read more

Start now free