browserserve
browserserve is a self-hosted browser server. One container runs isolated Chrome sessions over CDP, with an optional profile channel to save and restore session state. Run it standalone, or add it to the gateway as a provider.
browserserve is a self-hosted browser server from the same stack as the gateway. One container runs isolated Chrome sessions over CDP: a warm pool for instant starts, host-derived capacity, per-session resource caps, an optional profile channel to save and restore session state, and no cross-session state by default. It is a standalone product; it does not require the gateway.
Source and image:
- Repository: github.com/browser-gateway/browserserve (MIT or Apache-2.0)
- Image:
ghcr.io/browser-gateway/browserserve(multi-arch amd64/arm64, signed)
Quick start
docker run --rm -p 9222:9222 --shm-size=1g ghcr.io/browser-gateway/browserserve:latestThe image runs out of the box. Chromium's sandbox stays on where the host allows it; where the host blocks the system calls it needs (Docker's default profile, and most managed platforms), browserserve logs a warning and falls back to --no-sandbox automatically, so the browser still runs. Session isolation does not depend on the sandbox, so the fallback is safe. To keep the sandbox on, or to require it, see Security.
--shm-size=1g is recommended but no longer required to avoid crashes. Docker's default shared-memory area is 64 MiB, which is too small for Chromium under concurrency and causes renderer crashes. browserserve detects a too-small /dev/shm at startup and automatically routes shared memory to disk (--disable-dev-shm-usage), so it stays stable either way; giving it --shm-size=1g keeps the faster in-memory path. The size is reported by browserserve doctor and browserserve check.
Connect with any CDP client. Every WebSocket connection gets its own freshly launched browser:
// Puppeteer
const browser = await puppeteer.connect({ browserWSEndpoint: "ws://localhost:9222" });
// Playwright
const browser = await chromium.connectOverCDP("http://localhost:9222");By default, a session starts blank. On disconnect the whole process tree is killed and its profile directory is deleted, so cookies, localStorage, IndexedDB, and service workers do not carry into the next session. To make state persist on purpose, opt a session into a profile.
With token auth enabled (set BROWSERSERVE_TOKEN), pass the token as ?token=... on the URL or an Authorization: Bearer header.
Profiles
A profile lets a session start from saved state and captures that state back when the session closes, so a logged-in session can be resumed later. A profile has two layers:
- Portable core: cookies and localStorage. Applied and captured over CDP, so this layer works against any CDP browser, not only browserserve.
- Native layer: IndexedDB and service workers. browserserve owns the browser's disk, so it moves these as on-disk stores. A plain CDP connection cannot restore this layer at all, which matters for apps that keep their session in IndexedDB (many single-page apps and auth SDKs such as Firebase and Supabase). This is the one profile capability no remote provider can offer.
Use it through a one-shot token channel:
# 1. Drop off a profile, get a one-time token (bearer-authed, 64 MiB max, token lives ~2 min)
curl -X POST http://localhost:9222/v1/profile \
-H "Authorization: Bearer $BROWSERSERVE_TOKEN" \
-H "Content-Type: application/json" --data @profile.json
# -> { "profileToken": "…", "expiresInSec": 120 }
# 2. Connect a session seeded with that profile:
# ws://localhost:9222/?profileToken=<token>
# 3. After the session closes, pick up the captured state (single use):
curl http://localhost:9222/v1/profile/<token> \
-H "Authorization: Bearer $BROWSERSERVE_TOKEN"Add ?readOnly=1 to the connect URL to seed a profile without capturing it back, so any number of sessions can share one read-only profile at once.
localStorage is read from the browser's on-disk store after it exits, so every origin is captured, including ones that set no cookie. Cookies are restored through a drop-only sanitizer: it keeps every security attribute (Secure, HttpOnly, SameSite, partitioning) exactly, and drops any cookie it cannot reproduce safely rather than weakening it.
Current limitations, being tightened before a 1.0. IndexedDB and service workers are browserserve-native, so that layer does not transfer to a different provider. Service-worker restore fidelity is not yet independently measured. Importing a profile captured on a different Chromium build is untested. Cookies with an opaque partition key (CHIPS) are dropped. Profiles are proven on the portable isolation tier; the kernel-cgroup tier together with profiles has been validated in a Docker Desktop environment, with broad production validation ongoing.
If you run browserserve behind the gateway, the gateway manages profiles for you (encryption, locking, a dashboard, and a REST API). See Profiles for that workflow. The channel above is the standalone interface, for using browserserve on its own.
Isolation and resource control
browserserve detects what the host allows and reports it honestly. It resolves three independent tiers, and browserserve doctor and GET /pressure print all three:
- Kill.
cgroup.kill(a one-syscall tree kill) on a delegated Linux host, otherwise a process-group kill. - Memory cap. A kernel-enforced
memory.maxper session on a delegated host, otherwise a polled RSS soft cap. - Profile directory. How each session's directory is provisioned copy-on-write:
overlay,reflink,tmpfs-copy, orplain-copy, best first. A plain copy is a correctness-equivalent fallback; it behaves identically and is only less efficient.
The isolation contract (fresh browser, fresh profile directory, wiped on disconnect) holds on every tier. The kernel tier needs a writable, delegated cgroup v2 subtree (for example systemd Delegate=yes); in most containers the portable tier applies.
Capacity
When pool.maxSessions is unset, browserserve measures the host's real ceiling at startup rather than guessing. It brings up real recording sessions one at a time until the host reaches its edge (a launch failure or a memory safety line), then holds all of them together under full load for a window matching a real session and keeps the count that stays healthy. That is the honest number of sessions the host can actually run at once, and it depends on the machine: the same container gives a different number on a fast idle box than on a busy shared one, because a host can bring up more sessions gradually than it can sustain when they all run together. The measured number becomes the pool ceiling and is advertised on GET /json/version (as Browserserve-MaxConcurrent), so a gateway routing to this instance schedules it accurately instead of overloading it.
While calibration runs, the instance reports not-ready: GET /ready returns 503, GET /json/version carries Browserserve-Calibrating: true, and new sessions are refused so calibration has the host to itself. It finishes in seconds and begins accepting sessions the moment the ceiling is set; a gateway that probes during this window waits and retries.
Calibration runs once, at startup, and never drives the host into an out-of-memory: it only ever adds one session at a time and backs off before pressure. To skip it and use a fast, conservative estimate instead, set BROWSERSERVE_CALIBRATE=false (or pool.calibrate: false). That suits deploys that do not route through a gateway reading the advertised number, or that want instant-ready startup. Precedence: an explicit pool.maxSessions always wins, then calibration, then the estimate. GET /pressure reports the resulting ceiling and its source.
The estimate and the memory line both account for a limit that is easy to miss: on a container with a low pids.max, the real ceiling can be a handful of browsers even with plenty of free memory, because each Chrome spawns dozens of threads. If sessions start returning 503 well before memory is exhausted, that thread limit is usually why.
Startup and scale-to-zero
The server binds its port and answers GET /json/version in well under a second, then builds its warm pool in the background. It never blocks startup on a browser launch, so it comes up cleanly even on a slow or constrained host such as a managed platform.
Set pool.minReady: 0 for scale-to-zero: no browser runs while the instance is idle, and one launches on demand at the first connection. This trades a higher first-request latency for near-zero idle browser cost, which suits pay-as-you-go hosts. The default is 1, which keeps one browser warm for instant first responses.
Idle sessions and zombie clients
session.idleTimeoutMs kills a session whose client has stopped sending CDP messages for the configured number of milliseconds. This protects against zombie sessions where the client's TCP is still open but the client itself has hung or been abandoned. Set it via the config file or the BROWSERSERVE_IDLE_TIMEOUT_MS env var.
The default is 0 (disabled) to preserve behaviour for legitimate long-running sessions such as agent workflows, live debugging, and replays. A common starting value for operators who want protection: 300000 (5 min).
Only client outbound traffic resets the clock. Browser events, screencast frames, and log output do not, because they do not prove the client is still alive.
When the timer fires, the client's WebSocket receives a 1013 close with reason idle-timeout after {N}ms. Puppeteer and Playwright surface this as a clear error naming the idle timeout, so the operator knows immediately what happened. The browser process is killed and its slot returns to the pool.
The active value is reported on GET /pressure as idleTimeoutMs, so an operator can verify at runtime that their setting loaded.
Configuration
Configuration is a YAML file (browserserve.yml in the working directory, or BROWSERSERVE_CONFIG=<path>). Every field is optional. Unknown keys are rejected, so a typo fails fast at startup. The most useful keys:
| Key | Default | Meaning |
|---|---|---|
pool.minReady | 1 | Browsers kept launched and ready ahead of demand. Set to 0 for scale-to-zero (no idle browsers; launch on demand). |
pool.maxSessions | unset (auto) | Hard ceiling of concurrent browsers. Left unset, it is derived from the host. |
pool.calibrate | true | Measure the real ceiling at startup (see Capacity). Set false to skip it and use a fast conservative estimate. Ignored when pool.maxSessions is set. |
pool.maxQueue | 10 | Clients allowed to wait for a slot before rejection. |
pool.queueTimeoutMs | 30000 | How long a queued client waits before a 503. |
session.memoryMaxMb | 0 | Per-session memory cap in MiB (kernel-enforced on a delegated host, RSS soft cap otherwise; 0 = disabled, let the container's own memory limit be the OOM boundary). Recommended sizing when enabled: container_memory_mb / maxSessions × 0.8. |
session.idleTimeoutMs | 0 | Kill a session whose client has not sent a CDP message in this many ms; 0 = disabled. Recommended starting value: 300000 (5 min). |
session.killGraceMs | 5000 | SIGTERM-to-SIGKILL grace during teardown. |
pressure.maxCpuPercent | 95 | Reject new sessions above this host CPU usage. |
pressure.maxMemoryPercent | 95 | Reject new sessions above this host memory usage. |
chrome.noSandbox | false | Start without the Chromium sandbox. |
chrome.requireSandbox | false | Refuse to fall back to --no-sandbox if the host blocks the sandbox; sessions fail to start instead. For untrusted content. |
chrome.extraFlags | [] | Extra Chromium flags, appended after the built-in set. |
externalAddress | Host header | Public address advertised in webSocketDebuggerUrl; set this behind a proxy or load balancer. |
dataDir | .browserserve | Where per-session state lives. |
The repository's browserserve.example.yml lists every key with inline notes.
Environment variables
Every tuning knob has an environment-variable equivalent, so container and serverless deploys (Railway, Fly, Render, Cloud Run, K8s) never need a mounted YAML. Env vars override the YAML file.
| Variable | YAML equivalent | Meaning |
|---|---|---|
PORT | serve.port | HTTP port. Default 9222. |
HOST | serve.host | Bind address. Default 0.0.0.0. |
BROWSERSERVE_TOKEN | serve.token | Bearer token for auth. Unset disables auth. |
BROWSERSERVE_CONFIG | n/a | Path to the YAML config file. |
BROWSERSERVE_CHROME_PATH | chrome.executablePath | Chromium binary. Auto-detected when unset. |
BROWSERSERVE_DATA_DIR | dataDir | Where per-session state lives. |
BROWSERSERVE_MIN_READY | pool.minReady | Warm browsers to keep. 0 for scale-to-zero. |
BROWSERSERVE_MAX_SESSIONS | pool.maxSessions | Concurrent-session ceiling. Must be ≥ 1. |
BROWSERSERVE_CALIBRATE | pool.calibrate | Measure the real ceiling at startup. false skips it for a fast estimate. |
BROWSERSERVE_MAX_QUEUE | pool.maxQueue | Queued clients before rejection. |
BROWSERSERVE_QUEUE_TIMEOUT_MS | pool.queueTimeoutMs | Milliseconds a queued client waits before 503. |
BROWSERSERVE_IDLE_TIMEOUT_MS | session.idleTimeoutMs | Kill client-idle sessions after N ms. 0 disables. |
BROWSERSERVE_MEMORY_MAX_MB | session.memoryMaxMb | Per-session memory cap in MiB. 0 disables. |
BROWSERSERVE_NO_SANDBOX | chrome.noSandbox | Opt out of the Chromium sandbox. Boolean. |
BROWSERSERVE_REQUIRE_SANDBOX | chrome.requireSandbox | Refuse to fall back if the host blocks the sandbox. |
Setting both noSandbox and requireSandbox is a startup error.
Sizing and capacity
browserserve auto-derives pool.maxSessions from the host's real limits (cgroup memory / pids / cpus) and a measured browser footprint at boot. On an 8 GB, 8 vCPU container it typically resolves to 12-16 sessions, bound by CPU or PIDs. Override via pool.maxSessions or BROWSERSERVE_MAX_SESSIONS.
The per-session memory cap (session.memoryMaxMb) is disabled by default. A Chrome instance including its renderer, GPU, network, and storage processes typically uses 1-2 GB across the process tree even for a single tab, so a cap that seems generous can be tight in practice. Recommended pattern:
- Leave
memoryMaxMb: 0(default). The container's own memory limit (Docker--memory, K8sresources.limits.memory, Railway service memory) is the OOM boundary. - If you want a per-session ceiling for fair-share reasons, set it via the sizing math:
container_memory_mb / maxSessions × 0.8. On an 8 GB container with 16 max sessions, that's ~400 MB per session, which is too tight for anything but a single-tab scrape. On 4 max sessions it's 1.6 GB, which fits most workloads.
Screencast and playground use cases push memory harder than headless scrape. The compositor and paint pipeline stay hot; size accordingly.
Endpoints
| Endpoint | Purpose |
|---|---|
WS / | Connect a CDP session; one connection = one isolated browser. ?profileToken= seeds a saved profile, ?readOnly=1 shares one without capturing, ?token= authenticates. |
GET /json/version | CDP discovery. Also advertises Browserserve-Version and Browserserve-MaxConcurrent so a router can auto-detect the instance and its capacity. |
POST /v1/profile | Drop off a profile; returns a one-time profileToken. Bearer-authed, 64 MiB max, 429 when the store is full. |
GET /v1/profile/{token} | Pick up the captured profile after the session closes. Bearer-authed, single use, 404 until ready. |
GET /live | Process liveness. |
GET /ready | The instance can serve a session now. |
GET /pressure | Load, capacity (and which host limit set it via capacitySource), and the active isolation tiers. |
When BROWSERSERVE_TOKEN is set, the CDP WebSocket and the /v1/profile endpoints require the token; /live, /ready, and /pressure stay open for load balancers.
CLI
browserserve serve # run the server
browserserve check [--chrome PATH] # launch one browser, verify CDP readiness, tear down, report timings
browserserve doctor # diagnose the host: browser, fd limits, data dir, /dev/shm, isolation tiersAll three accept --config <path>. Config resolves in order: --config, then BROWSERSERVE_CONFIG, then ./browserserve.yml.
Security
- Sandbox with automatic fallback. Chromium's sandbox stays on where the host allows it. Where the host blocks the system calls it needs (Docker's default profile, Railway, Cloud Run, most managed platforms), browserserve logs a warning and falls back to
--no-sandboxso the deploy still works. Isolation between sessions is unaffected, because it comes from the fresh per-session profile directory, not the sandbox. To keep the sandbox on in Docker, run with the shipped profile:--security-opt seccomp=docker/seccomp.json. To require the sandbox and refuse the fallback, for untrusted content, setchrome.requireSandbox: true; sessions then fail to start on a host that blocks the sandbox rather than downgrading. To skip the sandbox explicitly, setchrome.noSandbox: true. - Non-root. The server does all real work as an unprivileged user (uid 999). It starts as root only to self-delegate a per-session cgroup slice when the host allows it, then drops privileges.
- Authenticated surface. With
BROWSERSERVE_TOKENset, the CDP WebSocket and the profile channel require the token; the health endpoints stay open.
The repository's SECURITY.md has the reporting policy and deployment notes.
Using browserserve with the gateway
Add it like any other provider. The gateway routes to it over the same WebSocket contract it uses for every provider, and auto-detects it from the Browserserve-Version and Browserserve-MaxConcurrent headers on /json/version (adopting its advertised capacity when you leave maxConcurrent unset):
providers:
browserserve:
url: ws://your-host:9222
limits:
maxConcurrent: 8
cloud-provider:
url: <websocket-url-with-auth>
priority: 2With token auth enabled on browserserve, bake the token into the URL: ws://your-host:9222?token=<token>.
A common shape: browserserve as the primary provider on your own hardware, with a cloud provider at a lower priority for failover. Because each session is a fresh, isolated browser, a single browserserve provider serves any number of profiles with no per-slot pinning and no crosstalk, and it is the only provider that carries the IndexedDB and service-worker layer.
Benchmarks
A same-host baseline comparison against Browserless and raw Chrome, with methodology and caveats, lives in the repository: docs/BENCHMARKS.md.
Supported Providers
browser-gateway works with any WebSocket endpoint. If your browser provider exposes a WebSocket URL, it works with the gateway.
Profiles
Persist browser state across sessions, cookies, localStorage, and on the self-hosted runtime IndexedDB and service workers, encrypted at rest with a key you control.