Read OSS

The Protocol Layer: How Client Talks to Server

Advanced

Prerequisites

  • Article 1: Architecture Overview
  • Understanding of RPC and message-passing patterns
  • TypeScript generics and class hierarchies

The Protocol Layer: How Client Talks to Server

In Part 1 we saw that Playwright separates every API call into a client half and a server half, connected by a protocol. This article traces a message end-to-end: from the moment you call page.click('.button') to the moment the server dispatches the actual click to the browser. Along the way, we'll dissect the ChannelOwner base class, the Connection message pump, the server-side Dispatcher hierarchy, the object lifecycle system, and the sync-then-async bootstrap optimization.

Protocol Schema and Code Generation

As we saw in Part 1, protocol.yml is the single source of truth. Each "channel" in the YAML is essentially an interface definition with three sections:

  • initializer: Properties sent when the object is first created (the __create__ message)
  • commands: Methods the client can call on the server
  • events: Messages the server pushes to the client

For example, the Playwright channel at packages/protocol/src/protocol.yml#L786-L801 declares an initializer with chromium, firefox, and webkit browser types, plus commands like newRequest.

During the build, utils/generate_channels.js produces three critical artifacts:

  1. Type definitions — TypeScript interfaces for every channel's params, results, events, and initializers
  2. Validators — Runtime functions that validate and transform messages on the wire
  3. Metainfo — Metadata about each method (is it internal? should it trigger slowMo?)

The validator functions are looked up at runtime via findValidator(type, method, kind), where kind is 'Params', 'Result', 'Event', or 'Initializer'. Both client and server call these validators on every message — the client validates outgoing params and incoming results, while the server validates incoming params and outgoing results.

flowchart TD
    A["protocol.yml"] -->|"generate_channels.js"| B["channels.d.ts"]
    A -->|"generate_channels.js"| C["validator.ts"]
    A -->|"generate_channels.js"| D["protocolMetainfo.ts"]
    
    B --> E["Compile-time type safety"]
    C --> F["Runtime message validation"]
    D --> G["Method metadata<br/>(internal, slowMo)"]

Tip: When adding a new method to Playwright's API, you start by editing protocol.yml. The generated types will then drive the implementation on both client and server sides, ensuring they stay in sync.

ChannelOwner: The Client-Side Base

Every client-side object — Page, Browser, Locator, Frame — ultimately inherits from ChannelOwner. This abstract base class, defined in packages/playwright-core/src/client/channelOwner.ts#L32-L64, provides:

  • A _guid that uniquely identifies the object across the protocol boundary
  • A _channel proxy that intercepts method calls and routes them through the protocol
  • A _connection reference to the parent Connection
  • A parent-child object tree for lifecycle management

The _channel proxy is the key mechanism. When you call page._channel.click(params), you're not calling a method directly. Instead, the proxy in _createChannel() at packages/playwright-core/src/client/channelOwner.ts#L145-L174 intercepts the call:

const channel = new Proxy(base, {
  get: (obj: any, prop: string | symbol) => {
    if (typeof prop === 'string') {
      const validator = maybeFindValidator(this._type, prop, 'Params');
      if (validator) {
        return async (params: any) => {
          return await this._wrapApiCall(async apiZone => {
            const validatedParams = validator(params, '', this._validatorToWireContext());
            return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone);
          });
        };
      }
    }
    return obj[prop];
  },
});

This proxy does three things: validates params against the protocol schema, wraps the call with instrumentation (stack traces, API logging), and sends the message through the connection.

classDiagram
    class ChannelOwner {
        +_guid: string
        +_type: string
        +_channel: T
        +_connection: Connection
        +_wrapApiCall()
        +_adopt()
        +_dispose()
    }
    class Playwright {
        +chromium: BrowserType
        +firefox: BrowserType
        +webkit: BrowserType
    }
    class BrowserType {
        +launch()
        +connect()
    }
    class Page {
        +click()
        +fill()
        +goto()
    }
    class Frame {
        +locator()
        +waitForSelector()
    }
    
    ChannelOwner <|-- Playwright
    ChannelOwner <|-- BrowserType
    ChannelOwner <|-- Page
    ChannelOwner <|-- Frame

Connection and Message Dispatch

The client Connection class at packages/playwright-core/src/client/connection.ts#L67-L90 is the message pump. It maintains two critical data structures:

  • _objects: A map from GUID to ChannelOwner — the client's view of all live objects
  • _callbacks: A map from message ID to pending promise resolve/reject pairs

Sending Messages

When sendMessageToServer() is called at packages/playwright-core/src/client/connection.ts#L127-L149, it:

  1. Assigns a monotonically increasing id
  2. Constructs a message: { id, guid, method, params }
  3. Attaches metadata (stack frames, title, stepId)
  4. Calls this.onmessage(message) — which is wired to the transport
  5. Returns a promise stored in _callbacks

Receiving Messages

The dispatch() method at packages/playwright-core/src/client/connection.ts#L159-L208 handles three types of incoming messages:

  1. Responses (have id): Look up the callback, validate the result, resolve or reject the promise
  2. Lifecycle events (__create__, __adopt__, __dispose__): Manage the client-side object tree
  3. Protocol events: Find the target object by GUID, validate, emit the event on its channel
sequenceDiagram
    participant User as User Code
    participant CO as ChannelOwner
    participant Conn as Connection
    participant Transport as Transport Layer

    User->>CO: page.click('.btn')
    CO->>CO: _channel proxy intercepts
    CO->>CO: validate params
    CO->>Conn: sendMessageToServer()
    Conn->>Conn: Assign id, store callback
    Conn->>Transport: onmessage({id, guid, method, params})
    Transport-->>Conn: Response {id, result}
    Conn->>Conn: dispatch() - find callback
    Conn->>Conn: validate result
    Conn-->>CO: resolve promise
    CO-->>User: return result

The Object Factory

When the server creates a new object (like a new Page), it sends a __create__ message. The _createRemoteObject() method at packages/playwright-core/src/client/connection.ts#L232-L342 handles this with a large switch statement mapping type names to constructors:

switch (type) {
  case 'Browser':
    result = new Browser(parent, type, guid, initializer);
    break;
  case 'Page':
    result = new Page(parent, type, guid, initializer);
    break;
  // ... 30+ more types
}

This is where the protocol types become concrete client objects. Every object you interact with was created through this switch.

Dispatcher: The Server-Side Bridge

On the server side, the mirror image of ChannelOwner is Dispatcher. Defined in packages/playwright-core/src/server/dispatchers/dispatcher.ts#L49-L83, each Dispatcher wraps an SdkObject (the actual implementation) and bridges it to the protocol.

When a Dispatcher is constructed, it:

  1. Registers itself with the DispatcherConnection
  2. Sends a __create__ message to the client with its type, GUID, and initializer
  3. Checks if stale dispatchers should be garbage collected

The _runCommand() method at packages/playwright-core/src/server/dispatchers/dispatcher.ts#L103-L111 wraps every command execution in a ProgressController for timeout management:

async _runCommand(callMetadata: CallMetadata, method: string, validParams: any) {
  const controller = ProgressController.createForSdkObject(this._object, callMetadata);
  this._activeProgressControllers.add(controller);
  try {
    return await controller.run(progress => (this as any)[method](validParams, progress), validParams?.timeout);
  } finally {
    this._activeProgressControllers.delete(controller);
  }
}

The DispatcherConnection Message Router

The server-side DispatcherConnection at packages/playwright-core/src/server/dispatchers/dispatcher.ts#L197-L207 handles incoming messages from the client. Its dispatch() method at line 299:

  1. Looks up the target dispatcher by GUID
  2. Validates params and metadata
  3. Constructs CallMetadata with timing, attribution, and location info
  4. Fires instrumentation.onBeforeCall() for tracing
  5. Runs the command via _runCommand()
  6. Validates the result
  7. Fires instrumentation.onAfterCall()
  8. Sends the response (or error) back to the client
sequenceDiagram
    participant Client as Client Connection
    participant DC as DispatcherConnection
    participant D as PageDispatcher
    participant SO as Page (SdkObject)
    participant Inst as Instrumentation

    Client->>DC: {id, guid, method:"click", params}
    DC->>DC: Find dispatcher by GUID
    DC->>DC: Validate params
    DC->>Inst: onBeforeCall()
    DC->>D: _runCommand("click", params)
    D->>D: ProgressController.run()
    D->>SO: Execute click
    SO-->>D: Result
    D-->>DC: Result
    DC->>DC: Validate result
    DC->>Inst: onAfterCall()
    DC-->>Client: {id, result}

Object Lifecycle and GC

Playwright manages a tree of objects on both sides. Three protocol messages control this lifecycle:

  • __create__: Server creates a new dispatcher → client creates matching ChannelOwner
  • __adopt__: Re-parents an object (e.g., moving a Page between contexts)
  • __dispose__: Destroys an object and all its children

The client-side _dispose() at packages/playwright-core/src/client/channelOwner.ts#L117-L128 recursively disposes children:

_dispose(reason: 'gc' | undefined) {
  if (this._parent)
    this._parent._objects.delete(this._guid);
  this._connection._objects.delete(this._guid);
  this._wasCollected = reason === 'gc';
  for (const object of [...this._objects.values()])
    object._dispose(reason);
  this._objects.clear();
}

The server adds a garbage collection layer. The maybeDisposeStaleDispatchers() method at packages/playwright-core/src/server/dispatchers/dispatcher.ts#L283-L297 prevents unbounded heap growth by tracking dispatchers per "GC bucket" (typically the type name). When a bucket exceeds its limit (100K for JSHandle/ElementHandle, 10K for others), the oldest 10% are disposed with reason: 'gc'.

flowchart TD
    A["New Dispatcher Created"] --> B{"Bucket size ><br/>maxDispatchers?"}
    B -->|No| C["Register normally"]
    B -->|Yes| D["Dispose oldest 10%"]
    D --> E["Send __dispose__(reason:'gc')<br/>to client"]
    E --> F["Client marks<br/>_wasCollected = true"]

Tip: If you see "The object has been collected to prevent unbounded heap growth" in Playwright, it means you're holding too many handles without disposing them. Use await handle.dispose() or work with Locators instead, which don't hold server-side references.

In-Process Wiring and the Sync-Async Bootstrap

The in-process wiring in packages/playwright-core/src/inProcessFactory.ts#L26-L58 deserves a closer look. Here's the key sequence:

// Step 1: Synchronous dispatch
dispatcherConnection.onmessage = message => clientConnection.dispatch(message);
clientConnection.onmessage = message => dispatcherConnection.dispatch(message);

// Step 2: Create and initialize
const rootScope = new RootDispatcher(dispatcherConnection);
new PlaywrightDispatcher(rootScope, playwright);
const playwrightAPI = clientConnection.getObjectWithKnownName('Playwright');

// Step 3: Switch to async
dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message));
clientConnection.onmessage = message => setImmediate(() => dispatcherConnection.dispatch(message));

The synchronous phase is critical. When PlaywrightDispatcher is constructed, it sends __create__ messages for the Playwright object, its BrowserTypes, and other initial objects. Because dispatch is synchronous, these messages are processed immediately and getObjectWithKnownName('Playwright') returns the fully initialized object.

After initialization, switching to setImmediate() prevents potential stack overflows: a deeply nested chain of protocol calls would otherwise grow the call stack unboundedly.

Also note clientConnection.useRawBuffers() — when client and server share the same process, binary data (like screenshots) is passed as raw Buffer objects instead of being base64-encoded, avoiding unnecessary copies.

The toImpl bridge at line 50 is a debugging escape hatch — it lets the test runner reach through the protocol to access server-side objects directly, which is essential for the fixture system (as we'll see in Article 5).

What's Next

Now that you understand how messages flow between client and server, the next article dives into what happens on the server side once a command arrives: how Playwright abstracts over three different browsers through its BrowserType → Browser → BrowserContext → Page hierarchy, the PageDelegate interface, and the instrumentation system that powers tracing and debugging.