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 RequestsTwo environment variables control this entirely:
CONCURRENT— sessions running right nowQUEUED— 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=10GitHub 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=44 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.largeon 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 /metricsKey metrics to watch:
browserless_sessions_active— current concurrent sessionsbrowserless_sessions_queued— sessions waitingbrowserless_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:
- Browserless
TIMEOUT: maximum time a session can exist on the server (default: 30 seconds) - Client-side timeout: Puppeteer's
setDefaultNavigationTimeout, Playwright'stimeoutin 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 configThis way: if a test hangs, your test runner gets a clean timeout error. If Browserless kills it, the error is distinguishable (408 vs TimeoutError).