Playwright with Browserless: Remote Browser Execution
Playwright supports remote browser execution through its CDP (Chrome DevTools Protocol) connection. Browserless exposes exactly this interface. The result: your Playwright tests run unchanged, but the browser lives on a remote server.
How the Connection Works
Normally, Playwright spawns a browser process locally:
const browser = await chromium.launch();With Browserless, instead of launching, you connect over WebSocket:
const browser = await chromium.connectOverCDP('wss://chrome.browserless.io?token=TOKEN');Browserless handles the Chrome process. Your code handles everything else. The API surface is identical once you have the browser object.
Connecting to Browserless
Hosted (browserless.io)
import { chromium } from 'playwright';
const browser = await chromium.connectOverCDP(
`wss://chrome.browserless.io?token=${process.env.BROWSERLESS_TOKEN}`
);
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();Self-Hosted
Same code, different URL:
const browser = await chromium.connectOverCDP('ws://localhost:3000');
// Or with token:
const browser = await chromium.connectOverCDP(
`ws://localhost:3000?token=${process.env.BROWSERLESS_TOKEN}`
);Running Existing Playwright Tests Against Browserless
You probably don't want to rewrite your tests. The cleanest approach is to override the browser launch at the fixture level using Playwright's use configuration.
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const BROWSERLESS_WS = process.env.BROWSERLESS_WS_ENDPOINT;
export default defineConfig({
use: {
// When BROWSERLESS_WS is set, connect remotely.
// Otherwise, Playwright launches a local browser (for local dev).
...(BROWSERLESS_WS
? { connectOptions: { wsEndpoint: BROWSERLESS_WS } }
: {}),
baseURL: 'https://your-app.com',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});Set the environment variable to switch between local and remote execution:
# Local run (uses local Chrome)
npx playwright test
# Remote run (uses Browserless)
BROWSERLESS_WS_ENDPOINT="wss://chrome.browserless.io?token=YOUR_TOKEN" \
npx playwright testNo test file changes required.
Custom Fixture Approach
If you need more control — different browsers, per-test timeouts, custom contexts — override the browser fixture:
// fixtures.ts
import { test as base, chromium } from '@playwright/test';
type BrowserFixtures = {
// nothing extra needed — just override base browser
};
export const test = base.extend<BrowserFixtures>({
browser: async ({}, use) => {
const wsEndpoint = process.env.BROWSERLESS_WS_ENDPOINT;
const browser = wsEndpoint
? await chromium.connectOverCDP(wsEndpoint)
: await chromium.launch();
await use(browser);
await browser.close();
},
});
export { expect } from '@playwright/test';In your test files, import from fixtures.ts instead of @playwright/test:
import { test, expect } from './fixtures';
test('homepage loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/My App/);
});Handling the Auth Token
The token goes in the WebSocket URL query string. That means it appears in logs and process lists if you're not careful.
Options:
- Environment variable (always):
process.env.BROWSERLESS_TOKEN— never hardcode. Authorizationheader: Browserless v2 acceptsAuthorization: Bearer TOKENas an alternative to the query param. Playwright'sconnectOverCDPdoesn't directly support custom headers on the WS connection, so the query param approach is typical.- Private network: If Browserless runs inside your VPC and is unreachable from outside, token protection matters less. Still use it — defense in depth.
For CI (GitHub Actions example):
env:
BROWSERLESS_WS_ENDPOINT: ${{ secrets.BROWSERLESS_WS_ENDPOINT }}Store the full URL including the token as the secret value: wss://chrome.browserless.io?token=abc123. This way the token is opaque in your config files.
Context and Session Management
connectOverCDP gives you a single browser that maps to one session on Browserless. Each browser.newContext() creates an isolated context (separate cookies, localStorage) within that session.
Parallel test workers each open their own connectOverCDP connection, consuming one concurrent session per worker. If you're running 4 Playwright workers against a Browserless instance configured with CONCURRENT=2, two workers will be queued.
Match your CONCURRENT setting to your --workers value:
# 5 workers = needs CONCURRENT >= 5 on Browserless
npx playwright test --workers=5Trace Files and Artifacts
Playwright's trace collection works normally over a remote connection. Traces are written to your local filesystem — Browserless just executes the browser commands; it doesn't store anything.
// playwright.config.ts
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
}After a test failure:
npx playwright show-reportThe report includes traces, screenshots, and videos captured during remote execution, stored locally.
Performance Considerations
Network latency is the main cost. Each Playwright command (click, fill, waitForSelector) is a round trip over the WebSocket. On a LAN or same-datacenter setup this is negligible (<1ms). Over the public internet, complex tests can slow down noticeably.
Mitigations:
- Run Browserless in the same region as your CI runners
- For self-hosted: run Browserless on the same host or cluster as your test runner
- Batch DOM operations where possible (evaluate() for bulk reads vs individual
textContent()calls)
Avoid re-connecting per test. Opening a new connectOverCDP for every test adds 200–500ms of session startup time. Use a shared browser at the suite level and create new contexts per test instead.
// playwright.config.ts
// One browser shared across tests, new context per test (Playwright's default)
use: {
connectOptions: {
wsEndpoint: process.env.BROWSERLESS_WS_ENDPOINT,
}
}When This Setup Makes Sense
Playwright with Browserless works well when:
- CI environments can't install Chrome — locked-down containers, minimal base images
- You want to test against a consistent Chrome version without updating browser installs across machines
- You're scaling parallel tests and don't want 20 Chrome instances eating memory on your CI runner
- Your tests run in a serverless or ephemeral environment that doesn't support browser processes
It adds latency for local development, so the typical pattern is: local dev uses chromium.launch(), CI uses Browserless.