17 KiB
Playwright Library Architecture: Client, Server, and Dispatchers
Playwright uses a client-server architecture connected by a protocol layer. The client provides the public API, the server performs actual browser automation, and dispatchers bridge the two over an RPC channel.
Package Layout
packages/protocol/src/
protocol.yml — RPC protocol definition (source of truth)
channels.d.ts — generated TypeScript channel interfaces
callMetadata.d.ts — call metadata types
packages/playwright-core/src/
client/ — public API objects (ChannelOwner subclasses)
server/ — browser automation implementation (SdkObject subclasses)
server/dispatchers/ — protocol bridge (Dispatcher subclasses)
protocol/ — validators (generated + primitives)
utils/isomorphic/ — shared code used by both client and server
protocolMetainfo.ts — generated method metadata (flags, titles)
Dependency Rules (DEPS.list)
Each directory has a DEPS.list constraining its imports. These are enforced by npm run flint.
client/ can import from:
../protocol/— validators and channel types../utils/isomorphic— shared utilities
server/ can import from:
../protocol/,../utils/,../utils/isomorphic/,../utilsBundle.ts./(own directory),./codegen/,./isomorphic/,./har/,./recorder/,./registry/,./utils/- Only
playwright.tscan import browser engines (./chromium/,./firefox/,./webkit/,./bidi/,./android/,./electron/) - Only
devtoolsController.tscan additionally import./chromium/
server/dispatchers/ can import from:
../../protocol/,../../utils/,../../utils/isomorphic/../**— all server modules
Key rule: Client code NEVER imports server code. Server code NEVER imports client code. They communicate only through the protocol.
Protocol Layer
protocol.yml
Defines all RPC interfaces, commands (methods), events, and types. Example:
Page:
type: interface
extends: EventTarget
initializer:
mainFrame: Frame
viewportSize: { type: object?, properties: { width: int, height: int } }
commands:
goto:
parameters:
url: string
timeout: float
waitUntil: LifecycleEvent?
returns:
response: Response?
events:
close: {}
navigated:
url: string
name: string
Code Generation
Running node utils/generate_channels.js (or via watch) produces:
packages/protocol/src/channels.d.ts— TypeScript types:PageChannel,PageGotoParams,PageGotoResult,PageInitializer, event typespackages/playwright-core/src/protocol/validator.ts— runtime validators:scheme.PageGotoParams = tObject({...})packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts— method flags (slowMo, snapshot, etc.)
Wire Format
Client → Server (RPC call): { id, guid, method, params, metadata? }
Server → Client (response): { id, result } or { id, error, log? }
Server → Client (event): { guid, method, params }
Server → Client (lifecycle): { guid, method: '__create__'|'__adopt__'|'__dispose__', params }
Object references are serialized as { guid: "object-guid" } and resolved by validators.
Client Layer
ChannelOwner — Base Class
Every client-side API object (Page, Frame, Browser, etc.) extends ChannelOwner<T>:
packages/playwright-core/src/client/channelOwner.ts
Key properties:
_connection: Connection— the RPC connection_channel: T— Proxy that intercepts method calls and sends RPC messages_guid: string— unique identifier matching the server-side object_type: string— type name (e.g., 'Page', 'Frame')_parent: ChannelOwner— parent in the object tree_objects: Map<string, ChannelOwner>— child objects_initializer— initial state received from server on creation
How _channel works: It's a Proxy. When you call this._channel.goto(params):
- Proxy intercepts the
gotoproperty access - Finds the validator for
PageGotoParams - Returns an async function that validates params, wraps in
_wrapApiCall, and calls_connection.sendMessageToServer()
Event subscription optimization: _eventToSubscriptionMapping maps JS event names to protocol subscription events. When the first listener is added, calls updateSubscription(event, true) on the channel. When last listener is removed, calls updateSubscription(event, false). This way the server only sends events that have listeners.
Connection
packages/playwright-core/src/client/connection.ts
Manages the client-server transport:
_objects: Map<string, ChannelOwner>— all live remote objects by GUID_callbacks: Map<number, {resolve, reject}>— pending RPC calls by message IDsendMessageToServer(object, method, params, apiZone)— sends RPC call, returns promisedispatch(message)— handles incoming messages:- Response (has
id): resolves/rejects the matching callback __create__: instantiates ChannelOwner subclass via factory switch__adopt__: reparents a child object__dispose__: disposes object and all children- Event (has
method): emits on the object's_channel
- Response (has
Representative Client Classes
| Class | File | Key delegation |
|---|---|---|
Playwright |
playwright.ts |
Root object; owns chromium, firefox, webkit BrowserTypes |
BrowserType |
browserType.ts |
launch() → _channel.launch() |
Browser |
browser.ts |
newContext() → _channel.newContext() |
BrowserContext |
browserContext.ts |
Owns pages, routes, tracing, cookies |
Page |
page.ts |
Delegates most calls to _mainFrame; owns keyboard/mouse/touchscreen |
Frame |
frame.ts |
goto(), click(), evaluate() → _channel.* |
Locator |
locator.ts |
Delegates to Frame methods with selector + strict: true |
ElementHandle |
elementHandle.ts |
DOM element reference |
Public API Exports
packages/playwright-core/src/client/api.ts exports all public classes.
Server Layer
SdkObject — Base Class
Every server-side domain object extends SdkObject:
packages/playwright-core/src/server/instrumentation.ts
Key properties:
guid: string— unique identifier (shared with client-side ChannelOwner)attribution: Attribution— ownership chain:{ playwright, browserType?, browser?, context?, page?, frame? }instrumentation: Instrumentation— hooks for tracing, debugging, test runner integration
Attribution is inherited from parent on construction. Instrumentation hooks include:
onBeforeCall, onAfterCall, onBeforeInputAction, onCallLog, onPageOpen/Close, onBrowserOpen/Close, onDialog, onDownload.
Key Server Classes
| Class | File | Purpose |
|---|---|---|
Playwright |
playwright.ts |
Root entry point; creates BrowserTypes |
BrowserType |
browserType.ts |
Launches browser processes |
Browser |
browser.ts |
Abstract base; owns BrowserContexts |
BrowserContext |
browserContext.ts |
Isolation boundary; owns pages, cookies, routes |
Page |
page.ts |
Owns FrameManager, workers; delegates to PageDelegate |
FrameManager |
frames.ts |
Manages frame hierarchy |
Frame |
frames.ts |
Navigation, DOM queries, JavaScript evaluation |
ElementHandle |
dom.ts |
DOM element operations |
ProgressController |
progress.ts |
Wraps async operations with timeout/cancellation/logging |
PageDelegate Pattern
Page delegates browser-specific operations to a PageDelegate interface:
interface PageDelegate {
navigateFrame(frame, url, referer): Promise<GotoResult>;
takeScreenshot(progress, format, ...): Promise<Buffer>;
adoptElementHandle(handle, to): Promise<ElementHandle>;
// ... more browser-specific operations
}
Implementations:
packages/playwright-core/src/server/chromium/crPage.ts— uses CDPpackages/playwright-core/src/server/firefox/ffPage.tspackages/playwright-core/src/server/webkit/wkPage.ts
Browser Engine Directories
| Directory | Protocol | Key files |
|---|---|---|
chromium/ |
Chrome DevTools Protocol (CDP) | crBrowser.ts, crPage.ts, crConnection.ts |
firefox/ |
Firefox internal protocol | ffBrowser.ts, ffPage.ts, ffConnection.ts |
webkit/ |
WebKit internal protocol | wkBrowser.ts, wkPage.ts, wkConnection.ts |
bidi/ |
WebDriver BiDi | bidiChromium.ts, bidiFirefox.ts |
android/ |
ADB | android.ts |
electron/ |
Electron/CDP | electron.ts |
Dispatcher Layer
Dispathers do not implement things, they translate protocol to the server code calls.
Dispatcher — Base Class
packages/playwright-core/src/server/dispatchers/dispatcher.ts
Dispatchers bridge server objects to the protocol. Each wraps an SdkObject and exposes methods matching the protocol channel.
class Dispatcher<Type extends SdkObject, ChannelType, ParentScopeType extends DispatcherScope>
Key properties:
connection: DispatcherConnection— the server-side connection_object: Type— the wrapped server object_guid: string— same GUID as the server object_type: string— type name matching protocol_parent: ParentScopeType— parent dispatcher_dispatchers: Map<string, DispatcherScope>— child dispatchers
Key methods:
_dispatchEvent(method, params)— sends event to client viaconnection.sendEvent()_runCommand(callMetadata, method, params)— wraps method call inProgressController, callsthis[method](params, progress)_dispose()— recursively disposes self and children, sends__dispose__to clientadopt(child)— reparents child dispatcher, sends__adopt__to clientaddObjectListener(event, handler)— listens on wrapped server object, auto-cleaned on dispose
Dispatcher Creation Pattern
Dispatchers use a static factory to ensure one-dispatcher-per-object:
static from(parentScope, object): XxxDispatcher {
return parentScope.connection.existingDispatcher<XxxDispatcher>(object) || new XxxDispatcher(parentScope, object);
}
The constructor sends __create__ to the client with the initializer data.
DispatcherConnection
Server-side counterpart to client's Connection:
_dispatcherByGuid— all dispatchers by GUID_dispatcherByObject— maps server objects to their dispatchers (ensures 1:1)dispatch(message)— validates params, createsCallMetadata, calls instrumentation hooks, runs dispatcher method, validates result, sends responsesendCreate/sendAdopt/sendDispose/sendEvent— lifecycle messages to client- GC: buckets with limits (JSHandle/ElementHandle: 100k, others: 10k); oldest 10% disposed when exceeded
Dispatcher Hierarchy
RootDispatcher
└── PlaywrightDispatcher
├── BrowserTypeDispatcher (per engine)
│ └── BrowserDispatcher
│ └── BrowserContextDispatcher
│ ├── PageDispatcher
│ │ ├── FrameDispatcher (main + child frames)
│ │ ├── WorkerDispatcher
│ │ └── ...
│ ├── TracingDispatcher
│ └── APIRequestContextDispatcher
├── AndroidDispatcher
├── ElectronDispatcher
└── LocalUtilsDispatcher
Key Dispatcher Files
| File | Dispatches for |
|---|---|
playwrightDispatcher.ts |
Playwright, BrowserType registration |
browserTypeDispatcher.ts |
BrowserType (launch, connect) |
browserDispatcher.ts |
Browser |
browserContextDispatcher.ts |
BrowserContext |
pageDispatcher.ts |
Page, Worker, BindingCall |
frameDispatcher.ts |
Frame |
networkDispatchers.ts |
Request, Response, Route, WebSocket, APIRequestContext |
elementHandlerDispatcher.ts |
ElementHandle |
jsHandleDispatcher.ts |
JSHandle |
dialogDispatcher.ts |
Dialog |
tracingDispatcher.ts |
Tracing |
artifactDispatcher.ts |
Artifact |
End-to-End Flow Example
await page.goto('https://example.com'):
CLIENT:
Page.goto()
→ _wrapApiCall() captures stack trace, creates ApiZone
→ _channel.goto({ url, timeout })
→ Proxy validates PageGotoParams
→ connection.sendMessageToServer(page, 'goto', params)
→ sends { id: 1, guid: 'page@abc', method: 'goto', params: {...} }
→ waits on callback promise
SERVER:
DispatcherConnection.dispatch(message)
→ validates PageGotoParams (wire → objects)
→ creates CallMetadata
→ instrumentation.onBeforeCall()
→ PageDispatcher._runCommand('goto', params)
→ ProgressController.run(progress => this.goto(params, progress))
→ PageDispatcher.goto(): this._object.mainFrame().goto(progress, url, params)
→ Frame.goto() → PageDelegate.navigateFrame() → CDP/protocol call
→ validates PageGotoResult (objects → wire)
→ instrumentation.onAfterCall()
→ sends { id: 1, result: { response: { guid: 'response@xyz' } } }
CLIENT:
connection.dispatch(response)
→ validates PageGotoResult (wire → objects)
→ resolves callback promise
→ _wrapApiCall completes, returns Response object
Object Lifecycle
- Creation: Server creates SdkObject → dispatcher constructor sends
__create__→ clientConnection.dispatch()instantiatesChannelOwnersubclass - Adoption:
dispatcher.adopt(child)sends__adopt__→ client reparents theChannelOwner - Disposal:
dispatcher._dispose()recursively disposes children → sends__dispose__→ client removesChannelOwnerfrom maps - GC: Server-side
maybeDisposeStaleDispatchers()evicts oldest dispatchers per bucket when limits exceeded
Testing: tests/library vs tests/page
Tests live in two directories under tests/, each with distinct scope and fixtures.
tests/library — API and Feature Tests
Tests the Playwright public API surface, browser lifecycle, and feature-level behavior. Uses browserTest fixtures which provide direct access to browser, browserType, context, and contextFactory.
import { browserTest as test, expect } from '../config/browserTest';
test('should create new page', async ({ browser }) => {
const page = await browser.newPage();
expect(browser.contexts().length).toBe(1);
await page.close();
});
What belongs here:
- Browser and BrowserType API (
launch,connect,version,newContext) - BrowserContext API (cookies, storage state, permissions, proxy, CSP, geolocation, network interception at context level)
- Browser-specific features (
chromium/for CDP, tracing, extensions, JS/CSS coverage, OOPIF;firefox/for launcher specifics) - Protocol and channel tests
- Inspector, codegen, and recorder features (
inspector/) - Event system tests (
events/) - Unit tests for internal utilities (
unit/)
Key fixtures (from browserTest): browser, browserType, context, contextFactory, launchPersistent, createUserDataDir, startRemoteServer, pageWithHar.
tests/page — Page Interaction Tests
Tests user-facing page interactions: clicking, typing, navigation, locators, assertions, and DOM operations. Uses pageTest fixtures which provide a ready-to-use page plus test servers.
import { test as it, expect } from './pageTest';
it('should click button', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/button.html');
await page.locator('button').click();
expect(await page.evaluate(() => window['result'])).toBe('Clicked');
});
What belongs here:
- Locator API (click, fill, type, select, query, filtering, convenience methods)
- ElementHandle interactions (click, screenshot, selection, bounding box)
- Expect/assertion matchers (boolean, text, value, accessibility)
- Page navigation (
goto,waitForNavigation,waitForURL) - Frame evaluation and hierarchy
- Request/response interception at page level
- JSHandle operations
- Screenshot and visual comparison tests
Key fixtures (from pageTest/serverFixtures): page, server, httpsServer, proxyServer, asset.
Decision Rule
| Question | → Directory |
|---|---|
| Does it test browser/context lifecycle or launch options? | tests/library |
| Does it test a browser-specific protocol feature (CDP, etc.)? | tests/library |
| Does it test user interaction with page content (click, type, assert)? | tests/page |
| Does it test locators, selectors, or DOM queries? | tests/page |
Does the test need direct browser or browserType access? |
tests/library |
Does the test just need a page and a test server? |
tests/page |
Running Tests
npm run ctest <file>— runs on Chromium only (fast, use during development)npm run test <file>— runs on all browsers (Chromium, Firefox, WebKit)
Examples:
npm run ctest tests/library/browser-context-cookies.spec.ts
npm run ctest tests/page/locator-click.spec.ts
npm run test tests/library/browser-context-cookies.spec.ts
Configuration
Both directories share a single config at tests/library/playwright.config.ts. It creates separate projects ({browserName}-library and {browserName}-page) pointing to their respective testDir.