Self-hosted, horizontally-scalable Playwright grid.
Spin up as many browser workers as you need on your own infrastructure and access them through a single WebSocket endpoint.
- Single WebSocket endpoint routes each session through a smart selector that balances load and staggers worker restarts.
- Warm browser instances (Chrome, Firefox, WebKit) - no waiting for browser startup.
- Each connection gets a fresh, isolated browser context.
- Stateless design: add or remove workers at any time; Redis is the only shared component.
- Works with any Playwright client.
Modern teams often need many concurrent browsers: scraping pipelines, AI agents, CI test suites, synthetic monitors⦠Spawning a fresh headless browser for every task wastes tens of seconds and huge amounts of memory. Existing SaaS grids solve this but:
- You may not want to ship data to a third-party cloud.
- Vendor pricing scales linearly with sessions.
- Latency to a remote grid quickly adds up.
playwright-distributed lets you run your own grid with Docker-simple deployment:
- Keep data on your infrastructure.
- Pay only for the compute you actually run (add/remove workers on demand).
- Share one endpoint across languages, teams and use-cases.
# 1. Grab the repo
git clone https://github.com/mbroton/playwright-distributed.git
cd playwright-distributed
# 2. Fire it up (proxy + 1 Chrome worker + Redis)
docker compose up -dConnect from your code:
import { chromium } from 'playwright';
const browser = await chromium.connect('ws://localhost:8080');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();Want Firefox or WebKit? Append
?browser=firefoxor?browser=webkitto the WebSocket URL and use the matching Playwright client (p.firefox.connect,p.webkit.connect, etc.). That's it! The samews://localhost:8080endpoint works with any Playwright client (Node.js, Python, Java, .NET, etc.).
| Scenario | Benefit |
|---|---|
| AI / LLM agents | Give each agent an isolated browser with zero startup cost. |
| Web scraping / data collection | Crawl at scale; add workers to raise throughput, remove them to save money. |
| CI end-to-end tests | Parallelise test runs across many browsers and cut build times dramatically. |
| Synthetic monitoring | Continuously exercise critical user journeys from multiple regions. |
| Shared βbrowser-as-a-serviceβ | One endpoint for your whole team β no more local browser zoo. |
Run each component (proxy, Redis, workers) as independent services (Docker/K8s). Checklist:
- Networking
- Workers β Redis (register, heartbeats)
- Proxy β Redis (worker discovery)
- Proxy β Workers (WebSocket forward)
- Exposure β expose only the proxy.
- Scaling β add or remove workers freely; the proxy always chooses the next worker according to the staggered-restart algorithm.
import { chromium, firefox, webkit } from 'playwright';
// Chromium workers connect without any query parameters.
const browser = await chromium.connect('ws://localhost:8080');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
// Target Firefox workers explicitly.
const firefoxBrowser = await firefox.connect('ws://localhost:8080?browser=firefox');
await firefoxBrowser.close();
// Or WebKit workers.
const webkitBrowser = await webkit.connect('ws://localhost:8080?browser=webkit');
await webkitBrowser.close();from playwright.async_api import async_playwright
import asyncio
async def main():
async with async_playwright() as p:
# Chromium (default)
browser = await p.chromium.connect('ws://localhost:8080')
context = await browser.new_context()
page = await context.new_page()
await page.goto('https://example.com')
print(await page.title())
await browser.close()
# Firefox
firefox = await p.firefox.connect('ws://localhost:8080?browser=firefox')
await firefox.close()
# WebKit
webkit = await p.webkit.connect('ws://localhost:8080?browser=webkit')
await webkit.close()
asyncio.run(main())Any Playwright-compatible client can connect to the same
ws://localhost:8080endpoint.
flowchart TD
Client[(Your Playwright code)] -->|WebSocket| Proxy
subgraph playwright-distributed
direction LR
Proxy -->|select worker| Redis[(Redis)]
Proxy <--> workerGroup
subgraph workerGroup [Workers]
direction LR
Worker1(Worker)
Worker2(Worker)
WorkerN(...)
end
Worker1 --> Redis
Worker2 --> Redis
WorkerN --> Redis
end
- One connection β One context β every websocket maps to a unique browser context.
- Concurrent sessions β each worker serves several contexts in parallel.
- Recycling β after serving a configurable number of sessions the worker shuts down; Docker/K8s restarts it, guaranteeing a fresh browser.
- Smart worker selection β the proxy's algorithm keeps workers from hitting their restart threshold at the same time and still favours the busiest eligible worker.
Here's what's planned for the near future:
- Documentation: Create comprehensive guides for deployment (K8s, bare metal) and various use-cases.
- Testing: Implement a full test suite to ensure stability and reliability.
Found a bug? Have an idea for improvement? PRs and issues are welcome!
This project is licensed under the Apache-2.0 License.
