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.
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)
Each directory has a DEPS.list constraining its imports. These are enforced by npm run flint.
Entries can be relative paths, alias paths (@isomorphic/**, @utils/**), or node_modules/<pkg> to allow a specific npm package import. The "strict" marker disables inheritance from parent folders. Section headers like [filename.ts] scope rules to a single file.
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.
Vendored npm packages (anything under node_modules/) go through src/utilsBundle.ts — a single bundled file that re-exports the vendored symbols. Adding a new dep or changing a DEPS.list entry for vendored code: see vendor.md.
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: stringRunning 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.)
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.
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.
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
| 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 |
packages/playwright-core/src/client/api.ts exports all public classes.
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.
| 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 |
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
| 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 |
Dispathers do not implement things, they translate protocol to the server code calls.
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
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.
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
RootDispatcher
└── PlaywrightDispatcher
├── BrowserTypeDispatcher (per engine)
│ └── BrowserDispatcher
│ └── BrowserContextDispatcher
│ ├── PageDispatcher
│ │ ├── FrameDispatcher (main + child frames)
│ │ ├── WorkerDispatcher
│ │ └── ...
│ ├── TracingDispatcher
│ └── APIRequestContextDispatcher
├── AndroidDispatcher
├── ElectronDispatcher
└── LocalUtilsDispatcher
| 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 |
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
- 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
Tests live in two directories under tests/, each with distinct scope and fixtures.
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 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.
| 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 |
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.tsBoth 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.