Playwright's Architecture: A Map of the Monorepo
Prerequisites
- ›Basic TypeScript knowledge
- ›Familiarity with npm workspaces concept
- ›General understanding of browser automation
Playwright's Architecture: A Map of the Monorepo
Playwright is a framework for cross-browser end-to-end testing and automation that supports Chromium, Firefox, and WebKit. But beneath the familiar page.click() API lies a surprisingly sophisticated client-server architecture — one designed from the ground up to support multiple programming languages, remote execution, and clean separation of concerns. This article maps the terrain: the monorepo structure, the fundamental client-server split, the boundary enforcement system, and the entry points that wire everything together.
Monorepo Structure and Package Roles
Playwright's repository is organized as an npm workspace with roughly 22 packages under packages/. The root package.json declares the workspace:
Here's how the key packages relate to each other:
| Package | npm Name | Role |
|---|---|---|
playwright-core |
playwright-core |
Core library: client, server, protocol, CLI |
playwright |
playwright |
Browser downloads + test fixtures + test runner |
playwright-test |
@playwright/test |
Thin CLI wrapper that reexports playwright |
protocol |
(internal) | YAML protocol definition + generated types |
injected |
(internal) | Scripts evaluated inside browser page contexts |
recorder |
(internal) | Recorder UI web app |
trace-viewer |
(internal) | Trace viewer web app |
html-reporter |
(internal) | HTML reporter web app |
web |
(internal) | Shared web utilities |
playwright-browser-* |
playwright-browser-chromium, etc. |
Browser-specific download packages |
The most important package is playwright-core — it contains both the client-side public API and the server-side browser control logic. The playwright package layers test fixtures, the test runner, and browser download management on top.
graph TD
AT["@playwright/test<br/>(CLI wrapper)"] --> PW["playwright<br/>(test runner + fixtures)"]
PW --> PC["playwright-core<br/>(core library)"]
PC --> PROTO["protocol<br/>(YAML + generated types)"]
PC --> INJ["injected<br/>(browser-side scripts)"]
PW --> REC["recorder<br/>(UI)"]
PW --> TV["trace-viewer<br/>(UI)"]
PW --> HR["html-reporter<br/>(UI)"]
Tip: When you
npm install playwright, you get the full package — test runner, browser downloads, and all. If you only need the automation API without the test runner, installplaywright-coreinstead.
The Client-Server Split
The most consequential design decision in Playwright is its client-server architecture. Even when you run Playwright from a single Node.js script, there are two distinct layers communicating via a protocol:
- Client (
packages/playwright-core/src/client/): The public API you interact with —Browser,Page,Locator, etc. These are lightweight objects that serialize method calls into protocol messages. - Server (
packages/playwright-core/src/server/): The actual browser control logic — process launching, CDP/WebSocket communication, DOM interaction, networking.
Why this split? Because Playwright supports four language bindings: JavaScript/TypeScript, Python, Java, and C#. The non-JavaScript bindings run the server as a child process and communicate over stdin/stdout. By having a clean protocol boundary, all languages share the exact same server implementation.
sequenceDiagram
participant JS as Node.js Client
participant Server as Playwright Server
participant Browser as Browser Process
JS->>Server: page.click('.button')
Note over JS,Server: Protocol message<br/>{guid, method, params}
Server->>Browser: CDP/Custom Protocol
Browser-->>Server: Result
Server-->>JS: Protocol response
The client-side root object is defined in packages/playwright-core/src/client/playwright.ts#L29-L61. It extends ChannelOwner and exposes the chromium, firefox, and webkit properties you use daily:
The server-side counterpart lives in packages/playwright-core/src/server/playwright.ts#L39-L66. Notice how the server Playwright instantiates the actual browser type implementations — Chromium, Firefox, WebKit — along with the BidiChromium and BidiFirefox variants for emerging WebDriver BiDi support.
Module Boundary Enforcement with DEPS.list
Most large monorepos use tools like Nx or Turborepo to enforce dependency boundaries. Playwright takes a different, lightweight approach: DEPS.list files checked by a custom utils/check_deps.js script during the build.
Each directory can have a DEPS.list file that declares which other modules its files may import. The format is simple: each section names a file and lists allowed import paths.
The top-level boundary rules are in packages/playwright-core/src/DEPS.list#L1-L32:
[inProcessFactory.ts]
**
[inprocess.ts]
utils/
server/utils
[outofprocess.ts]
client/
protocol/
utils/
utils/isomorphic
server/utils
The critical line is [inProcessFactory.ts] followed by ** — meaning this is the only file in the entire codebase allowed to import from both client and server. Every other file is restricted.
The client's boundary is even stricter. From packages/playwright-core/src/client/DEPS.list#L1-L3:
[*]
../protocol/
../utils/isomorphic
The client can only import protocol types and isomorphic utilities — never the server. This is enforced at build time, preventing accidental coupling.
flowchart LR
subgraph "Allowed Imports"
direction TB
C["client/"] -->|"protocol types only"| P["protocol/"]
C -->|"shared utils"| UI["utils/isomorphic/"]
S["server/"] --> P
S --> UI
S -->|"controlled"| SB["server/chromium/<br/>server/firefox/<br/>server/webkit/"]
end
IPF["inProcessFactory.ts"] -->|"** (everything)"| C
IPF --> S
style IPF fill:#f96,stroke:#333,color:#000
The server's DEPS.list at packages/playwright-core/src/server/DEPS.list#L1-L30 adds another layer: only playwright.ts (the server root) may import browser-specific modules like ./chromium/, ./firefox/, and ./webkit/. This prevents, say, the Page base class from accidentally importing Chromium-specific code.
Tip: If you're contributing to Playwright and the build fails with a dependency error, check the nearest
DEPS.listfile. It will tell you exactly which imports are legal for your file.
Entry Points: In-Process vs Out-of-Process
Playwright offers two modes of operation, corresponding to two entry points.
In-Process (Node.js default)
When you require('playwright-core'), you hit packages/playwright-core/src/inprocess.ts#L17-L19:
import { createInProcessPlaywright } from './inProcessFactory';
module.exports = createInProcessPlaywright();
This calls the critical wiring function in packages/playwright-core/src/inProcessFactory.ts#L26-L58, which:
- Creates the server-side
Playwrightinstance - Creates both a client
Connectionand serverDispatcherConnection - Wires them together with synchronous dispatch initially
- Initializes the Playwright channel
- Switches to asynchronous dispatch via
setImmediate()
flowchart TD
A["require('playwright-core')"] --> B["inprocess.ts"]
B --> C["createInProcessPlaywright()"]
C --> D["Create server Playwright"]
C --> E["Create client Connection"]
C --> F["Create DispatcherConnection"]
D --> G["Wire sync dispatch"]
G --> H["Initialize Playwright channel"]
H --> I["Switch to async dispatch<br/>(setImmediate)"]
I --> J["Return client PlaywrightAPI"]
The sync-then-async trick is worth understanding. During initialization, the client sends initialize() and expects to immediately get back the Playwright object with its chromium, firefox, and webkit properties. Synchronous dispatch ensures this handshake completes before createInProcessPlaywright() returns. After that, setImmediate() is used to prevent stack overflow from deeply nested protocol calls.
Out-of-Process (Other Language Bindings)
For Python, Java, and C# bindings, the entry point is packages/playwright-core/src/outofprocess.ts#L28-L68. This forks the Playwright driver as a child process and connects via PipeTransport over stdin/stdout:
this._driverProcess = childProcess.fork(
path.join(__dirname, '..', 'cli.js'), ['run-driver'], {
stdio: 'pipe',
detached: true,
});
The Connection then sends JSON messages through the pipe, and the server dispatches them identically to the in-process mode. This is how four languages share one server implementation.
sequenceDiagram
participant Python as Python Client
participant Pipe as stdin/stdout
participant Driver as Node.js Driver
participant Browser as Browser
Python->>Pipe: JSON message
Pipe->>Driver: PipeTransport
Driver->>Browser: CDP/Protocol
Browser-->>Driver: Response
Driver-->>Pipe: JSON response
Pipe-->>Python: Result
Protocol YAML and Code Generation
The single source of truth for the entire RPC API is packages/protocol/src/protocol.yml — a roughly 4,590-line YAML file that defines every channel (interface), method, event, and initializer.
Here's what a channel definition looks like, from the Root and Playwright channels starting at line 774:
Root:
type: interface
commands:
initialize:
internal: true
parameters:
sdkLanguage: SDKLanguage
returns:
playwright: Playwright
Playwright:
type: interface
initializer:
chromium: BrowserType
firefox: BrowserType
webkit: BrowserType
android: Android
electron: Electron
During the build, utils/generate_channels.js reads this YAML and generates:
- TypeScript channel interfaces (
packages/protocol/src/channels.d.ts) - Validator functions (
packages/playwright-core/src/protocol/validator.ts) - Protocol metainfo (
packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts)
The validators are used on both sides of the protocol boundary to ensure messages conform to the schema. This is Playwright's compile-time safety net for the RPC layer.
flowchart LR
YAML["protocol.yml<br/>(source of truth)"] --> GEN["generate_channels.js"]
GEN --> CH["channels.d.ts<br/>(TypeScript types)"]
GEN --> VAL["validator.ts<br/>(runtime validators)"]
GEN --> META["protocolMetainfo.ts<br/>(method metadata)"]
CH --> CLIENT["Client code"]
CH --> SERVER["Server code"]
VAL --> CLIENT
VAL --> SERVER
What's Next
This article gave you a map of the territory. In the next article, we'll zoom in on the protocol layer itself — tracing a message from a page.click() call through the client Connection, across the protocol boundary, into the server DispatcherConnection, and down to the actual browser command. We'll examine ChannelOwner, Dispatcher, object lifecycle management, and the validation pipeline that keeps client and server in sync.