A self-hosted browser service for Playwright. Use browsers from your application code while BrowserThing runs and manages them on separate workers.
See the code
Use Playwright in your app. Run browsers elsewhere.
A self-hosted browser service for Playwright. Use browsers from your application code while BrowserThing runs and manages them on separate workers.
Write browser tasks in your application code and connect to BrowserThing through one endpoint. BrowserThing keeps browsers running between tasks and handles session cleanup and browser recycling. Your team deploys and updates the service; each application uses it through Playwright.
Run workers on separate machines to keep browser CPU and memory use off your application servers. Add workers when you need more browser capacity.
Your application BrowserThing
Playwright commands ----------> Browser workers
Results <---------- Browsers stay running
BrowserThing was previously named playwright-distributed.
For existing installations, see upgrading after the rename.
You need Docker with the Compose plugin, curl, and Node.js 20 or later.
1. Start the service — the server, PostgreSQL, and one Chromium worker:
curl -LO https://raw.githubusercontent.com/mbroton/browserthing/main/docker-compose.yaml
curl --create-dirs -o worker/seccomp_profile.json https://raw.githubusercontent.com/mbroton/browserthing/main/worker/seccomp_profile.json
docker compose up -d
2. Install the Playwright client. The current release uses Playwright 1.63.0:
npm init -y
npm install playwright@1.63.0
3. Run a browser task. Save this as browser-task.mjs. This example saves
a screenshot. Replace the task with the browser actions your application needs:
import { chromium } from 'playwright';
const browser = await chromium.connect('ws://localhost:8080');
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'preview.png', fullPage: true });
} finally {
await browser.close();
}
Run the example:
node browser-task.mjs
The client saves preview.png locally. BrowserThing releases the session's
resources when the connection closes, so the browser can serve the next task.
This local setup runs workers on your machine. Use
separate worker hosts to move
browser resource use off your application server.
Your client's Playwright
major.minorversion must match a registered worker's version — the server routes each client to a version-matched worker.
When you need more browser capacity, add workers. Each
serves up to MAX_SLOTS (default 5,
how to tune it) concurrent sessions:
docker compose up -d --scale worker=5 # 25 session slots
For Firefox or WebKit, add a worker service with BROWSER_TYPE=firefox or
BROWSER_TYPE=webkit (copy the worker service in the compose file; see
docker-compose.local.yaml in the repository for a three-browser stack) and
connect with firefox.connect('ws://host:8080/?browser=firefox').
Keep the steps of each browser task in your application code. Use Playwright to navigate pages, interact with them, and use the results in your application. Connect when a task needs a browser and close the connection when it finishes. BrowserThing selects a worker and handles browser startup, cleanup, and recycling.
Several applications can share the same internal service. Browser capacity and updates are managed in one place, separate from each application's code.
Browser startup adds time and CPU use to short tasks. BrowserThing keeps browsers
running between connections. In this benchmark, each task opened and closed a
Playwright connection, and Browserless 2.56.0 launched a new browser process for
each connection. The comparison used an AWS m8i.xlarge (4 vCPUs, 16 GB), the
same Playwright version, one BrowserThing worker, and one Browserless node:
| BrowserThing | Browserless | |
|---|---|---|
| Get a browser, open a page, read it | 51 ms | 217 ms |
| CPU used per task | 0.09 s | 0.70 s |
| 1,000 such tasks, 5 at a time | 26 s | 154 s |
These results measure a short page-read task. They show the overhead saved when tasks open and close connections frequently. Browserless also supports session reuse, which this benchmark did not use. See benchmark details. Measure your own pages to estimate the benefit for your application.
Sessions use separate browser contexts for cookies and storage, but share the browser process on each worker:
flowchart TD
Client[(Your application: Playwright code)] -->|WebSocket| Server
subgraph BrowserThing
direction LR
Server -->|sessions, workers, API keys| PostgreSQL[(PostgreSQL)]
Server <-->|WebSocket relay| workerGroup
workerGroup -->|register / heartbeat, HTTP| Server
subgraph workerGroup [Workers]
direction LR
Worker1(Worker)
Worker2(Worker)
WorkerN(...)
end
end
MAX_SLOTS concurrent
sessions; every session creates its own isolated contexts on that browser.DRAIN_TIMEOUT are closed. Selection concentrates load on the
longest-serving worker, so recycles tend to happen one worker at a time.Run the server, PostgreSQL, and workers as independent services (Docker or Kubernetes):
docker compose exec server server apikey create --name <name>) and from
then on every request except health checks needs a key; hand it to every
worker (WORKER_API_KEY) and client
(chromium.connect('ws://host:8080/?token=pwd_...')) in the same step.See server/README.md for the full configuration and
API reference.
The container image paths are now
ghcr.io/mbroton/browserthing/server and
ghcr.io/mbroton/browserthing/worker. Existing images under
ghcr.io/mbroton/playwright-distributed/ remain available, but new releases
use only the browserthing paths. Update your image references to receive
future releases.
Keep your existing .env file and Compose project name when upgrading.
If you rename the deployment directory, first find the existing project name
with docker compose ls, then set COMPOSE_PROJECT_NAME=<existing-name> in
.env. This keeps Compose connected to the existing PostgreSQL volume.
The rename does not change the database name or user (pwd), API key format
(pwd_...), or worker session header (x-pwd-session-id). Existing keys and
server/worker connections remain compatible. Client and worker Playwright
versions must still match as described in the quick start.
BrowserThing is built for applications you trust. It trusts every authenticated
client (in bootstrap mode: every client that can reach the server) while letting
browsers visit untrusted pages. The
compose files bind the server to 127.0.0.1, keep PostgreSQL and workers on
an internal network, and run workers as a non-root user with Playwright's
Chromium sandbox profile.
An API key grants full browser and control-plane access, and authentication
does not encrypt plain http:///ws:// traffic — put the server behind a
TLS reverse proxy, VPN, or private network. Containers are hardening, not a
strong isolation boundary against hostile tenants or browser exploits; use
dedicated VMs where that boundary is required. See
Playwright's Docker security guidance.
from playwright.async_api import async_playwright
import asyncio
async def main():
async with async_playwright() as p:
browser = await p.chromium.connect('ws://localhost:8080')
try:
context = await browser.new_context()
page = await context.new_page()
await page.goto('https://example.com')
await page.screenshot(path='preview.png', full_page=True)
finally:
await browser.close()
asyncio.run(main())
Create a session through the API to get an ID you can inspect, connect to, and terminate:
curl -s localhost:8080/v1/capacity # slots and queue depth
curl -s localhost:8080/v1/workers # the whole grid
# Create a session, then connect to it by ID:
curl -s -X POST localhost:8080/v1/sessions \
-H 'Content-Type: application/json' \
-d '{"browser": "chromium", "playwright_version": "1.63.0"}'
# -> { "id": "..." } connect: chromium.connect('ws://localhost:8080/sessions/<id>')
curl -s localhost:8080/v1/sessions/<id> # inspect it
curl -X DELETE localhost:8080/v1/sessions/<id> # terminate it, even mid-use
Bugs and ideas are welcome — open an issue. Code changes should start as an issue too, so the approach is agreed on before anyone writes it.
Go
57.5%
TypeScript
35.3%
JavaScript
3.9%
Astro
2.7%
A self-hosted browser service for Playwright. Use browsers from your application code while BrowserThing runs and manages them on separate workers.
See the code
Use Playwright in your app. Run browsers elsewhere.
A self-hosted browser service for Playwright. Use browsers from your application code while BrowserThing runs and manages them on separate workers.
Write browser tasks in your application code and connect to BrowserThing through one endpoint. BrowserThing keeps browsers running between tasks and handles session cleanup and browser recycling. Your team deploys and updates the service; each application uses it through Playwright.
Run workers on separate machines to keep browser CPU and memory use off your application servers. Add workers when you need more browser capacity.
Your application BrowserThing
Playwright commands ----------> Browser workers
Results <---------- Browsers stay running
BrowserThing was previously named playwright-distributed.
For existing installations, see upgrading after the rename.
You need Docker with the Compose plugin, curl, and Node.js 20 or later.
1. Start the service — the server, PostgreSQL, and one Chromium worker:
curl -LO https://raw.githubusercontent.com/mbroton/browserthing/main/docker-compose.yaml
curl --create-dirs -o worker/seccomp_profile.json https://raw.githubusercontent.com/mbroton/browserthing/main/worker/seccomp_profile.json
docker compose up -d
2. Install the Playwright client. The current release uses Playwright 1.63.0:
npm init -y
npm install playwright@1.63.0
3. Run a browser task. Save this as browser-task.mjs. This example saves
a screenshot. Replace the task with the browser actions your application needs:
import { chromium } from 'playwright';
const browser = await chromium.connect('ws://localhost:8080');
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'preview.png', fullPage: true });
} finally {
await browser.close();
}
Run the example:
node browser-task.mjs
The client saves preview.png locally. BrowserThing releases the session's
resources when the connection closes, so the browser can serve the next task.
This local setup runs workers on your machine. Use
separate worker hosts to move
browser resource use off your application server.
Your client's Playwright
major.minorversion must match a registered worker's version — the server routes each client to a version-matched worker.
When you need more browser capacity, add workers. Each
serves up to MAX_SLOTS (default 5,
how to tune it) concurrent sessions:
docker compose up -d --scale worker=5 # 25 session slots
For Firefox or WebKit, add a worker service with BROWSER_TYPE=firefox or
BROWSER_TYPE=webkit (copy the worker service in the compose file; see
docker-compose.local.yaml in the repository for a three-browser stack) and
connect with firefox.connect('ws://host:8080/?browser=firefox').
Keep the steps of each browser task in your application code. Use Playwright to navigate pages, interact with them, and use the results in your application. Connect when a task needs a browser and close the connection when it finishes. BrowserThing selects a worker and handles browser startup, cleanup, and recycling.
Several applications can share the same internal service. Browser capacity and updates are managed in one place, separate from each application's code.
Browser startup adds time and CPU use to short tasks. BrowserThing keeps browsers
running between connections. In this benchmark, each task opened and closed a
Playwright connection, and Browserless 2.56.0 launched a new browser process for
each connection. The comparison used an AWS m8i.xlarge (4 vCPUs, 16 GB), the
same Playwright version, one BrowserThing worker, and one Browserless node:
| BrowserThing | Browserless | |
|---|---|---|
| Get a browser, open a page, read it | 51 ms | 217 ms |
| CPU used per task | 0.09 s | 0.70 s |
| 1,000 such tasks, 5 at a time | 26 s | 154 s |
These results measure a short page-read task. They show the overhead saved when tasks open and close connections frequently. Browserless also supports session reuse, which this benchmark did not use. See benchmark details. Measure your own pages to estimate the benefit for your application.
Sessions use separate browser contexts for cookies and storage, but share the browser process on each worker:
flowchart TD
Client[(Your application: Playwright code)] -->|WebSocket| Server
subgraph BrowserThing
direction LR
Server -->|sessions, workers, API keys| PostgreSQL[(PostgreSQL)]
Server <-->|WebSocket relay| workerGroup
workerGroup -->|register / heartbeat, HTTP| Server
subgraph workerGroup [Workers]
direction LR
Worker1(Worker)
Worker2(Worker)
WorkerN(...)
end
end
MAX_SLOTS concurrent
sessions; every session creates its own isolated contexts on that browser.DRAIN_TIMEOUT are closed. Selection concentrates load on the
longest-serving worker, so recycles tend to happen one worker at a time.Run the server, PostgreSQL, and workers as independent services (Docker or Kubernetes):
docker compose exec server server apikey create --name <name>) and from
then on every request except health checks needs a key; hand it to every
worker (WORKER_API_KEY) and client
(chromium.connect('ws://host:8080/?token=pwd_...')) in the same step.See server/README.md for the full configuration and
API reference.
The container image paths are now
ghcr.io/mbroton/browserthing/server and
ghcr.io/mbroton/browserthing/worker. Existing images under
ghcr.io/mbroton/playwright-distributed/ remain available, but new releases
use only the browserthing paths. Update your image references to receive
future releases.
Keep your existing .env file and Compose project name when upgrading.
If you rename the deployment directory, first find the existing project name
with docker compose ls, then set COMPOSE_PROJECT_NAME=<existing-name> in
.env. This keeps Compose connected to the existing PostgreSQL volume.
The rename does not change the database name or user (pwd), API key format
(pwd_...), or worker session header (x-pwd-session-id). Existing keys and
server/worker connections remain compatible. Client and worker Playwright
versions must still match as described in the quick start.
BrowserThing is built for applications you trust. It trusts every authenticated
client (in bootstrap mode: every client that can reach the server) while letting
browsers visit untrusted pages. The
compose files bind the server to 127.0.0.1, keep PostgreSQL and workers on
an internal network, and run workers as a non-root user with Playwright's
Chromium sandbox profile.
An API key grants full browser and control-plane access, and authentication
does not encrypt plain http:///ws:// traffic — put the server behind a
TLS reverse proxy, VPN, or private network. Containers are hardening, not a
strong isolation boundary against hostile tenants or browser exploits; use
dedicated VMs where that boundary is required. See
Playwright's Docker security guidance.
from playwright.async_api import async_playwright
import asyncio
async def main():
async with async_playwright() as p:
browser = await p.chromium.connect('ws://localhost:8080')
try:
context = await browser.new_context()
page = await context.new_page()
await page.goto('https://example.com')
await page.screenshot(path='preview.png', full_page=True)
finally:
await browser.close()
asyncio.run(main())
Create a session through the API to get an ID you can inspect, connect to, and terminate:
curl -s localhost:8080/v1/capacity # slots and queue depth
curl -s localhost:8080/v1/workers # the whole grid
# Create a session, then connect to it by ID:
curl -s -X POST localhost:8080/v1/sessions \
-H 'Content-Type: application/json' \
-d '{"browser": "chromium", "playwright_version": "1.63.0"}'
# -> { "id": "..." } connect: chromium.connect('ws://localhost:8080/sessions/<id>')
curl -s localhost:8080/v1/sessions/<id> # inspect it
curl -X DELETE localhost:8080/v1/sessions/<id> # terminate it, even mid-use
Bugs and ideas are welcome — open an issue. Code changes should start as an issue too, so the approach is agreed on before anyone writes it.
Go
57.5%
TypeScript
35.3%
JavaScript
3.9%
Astro
2.7%