Read OSS

Playwright's Architecture: A Map of the Monorepo

Intermediate

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:

package.json#L1-L5

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, install playwright-core instead.

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.list file. 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:

  1. Creates the server-side Playwright instance
  2. Creates both a client Connection and server DispatcherConnection
  3. Wires them together with synchronous dispatch initially
  4. Initializes the Playwright channel
  5. 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.