PRACTICAL GUIDE / Playwright WebSocket subprotocol testing

Test WebSocket Subprotocol Negotiation with Playwright

Learn Playwright WebSocket subprotocol testing with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.

By The Testing AcademyUpdated July 18, 202619 min read
All field guides
In this guide12 sections
  1. Define the Subprotocol Contract
  2. Check Playwright Version and API Semantics
  3. Inspect the Client Protocol Offer
  4. Prove the Live Server Selection
  5. Design Positive, Negative, and Boundary Cases
  6. Compare Observation, Mocking, Proxying, and Live Tests
  7. Capture Request, Frame, and UI Evidence
  8. Protect Protocol and Frame Data
  9. Debug Missing or Incorrect Negotiation Evidence
  10. Add Deterministic CI Gates
  11. Frequently Asked Questions
  12. What does webSocketRoute.protocols() return?
  13. How do I assert the negotiated WebSocket subprotocol?
  14. Should protocol preference order be tested?
  15. Can routeWebSocket reject an unsupported protocol?
  16. Why is my WebSocket route handler never called?
  17. Can HAR or trace prove WebSocket message compatibility?
  18. Practice the Full Negotiation Boundary

What you will learn

  • Define the Subprotocol Contract
  • Check Playwright Version and API Semantics
  • Inspect the Client Protocol Offer
  • Prove the Live Server Selection

Playwright WebSocket subprotocol testing should verify two separate facts: which protocols the page offered and which protocol a live server selected. Use webSocketRoute.protocols() to inspect the ordered client offer, then read the browser socket's protocol after opening for negotiation. Tie both checks to message handling and a visible application outcome.

That split prevents a deterministic mock from claiming more than it proves. A route can validate client intent and drive rare UI states, while a live handshake verifies deployed server choice. The broader Playwright WebSocket mocking guide covers frame control; this article concentrates on version negotiation and the evidence needed when an offered protocol, selected protocol, and message schema disagree.

Define the Subprotocol Contract

A WebSocket subprotocol names the application protocol carried over the upgraded connection. Teams often use values such as events.v2 and events.v1 to distinguish message envelopes, required acknowledgements, or compatibility rules. The string is part of the handshake, not a replacement for message validation.

Write the contract in four parts:

  • Offer: the ordered protocol list the browser client sends.
  • selection: the one protocol the server accepts, or an empty selection when none is negotiated.
  • behavior: the message schema and client reducer associated with that selection.
  • failure: the UI and reconnect policy when no supported protocol is available.

The first observable assertion should be a product state. A collaboration client might show "Connected" only after the selected protocol is accepted and an initial snapshot is parsed. A market feed might expose a version mismatch status instead of retrying forever. Record the transport facts that explain that state, but do not let a protocol-array assertion replace it.

State which dependency is real. A complete route mock proves that the page offered expected values and handled controlled frames. A proxy path preserves a real server connection while allowing selected frame observation or mutation. A direct live socket probe verifies the deployed handshake. A HAR network replay test is useful for HTTP exchanges around socket setup, but it is not a substitute for a current WebSocket server.

Choose protocol names that contain no credentials or tenant data. They are header values and can appear in traces, proxy logs, server access logs, and support artifacts. Authentication belongs in an approved cookie, header, or initial message design with its own security review.

Check Playwright Version and API Semantics

The Playwright release notes add webSocketRoute.protocols() in version 1.60. The installed 1.61.1 type is synchronous and returns string[]. It reports values passed through the page's WebSocket constructor, corresponding to the Sec-WebSocket-Protocol request header.

The official WebSocketRoute API makes three semantics especially important:

  • No protocols in the constructor produces an empty array.
  • A routed socket is fully mocked unless the handler calls connectToServer().
  • Installing onMessage() takes over forwarding for that direction, so the handler must send or forward each accepted message explicitly.

protocols() describes the client request. It does not expose a separate server-selected value. For that result, the browser-standard WebSocket.protocol property is authoritative after the socket's open event. Prefer a product-owned status or test probe that reads it while the connection is alive.

Do not infer selection from the first offered value. The list can express client preference, but the live server decides among compatible protocols according to its contract. A server may select a later offered value. If no protocol is selected and the connection still opens, the application must decide whether an empty protocol is acceptable.

Only sockets created after page.routeWebSocket() or browserContext.routeWebSocket() registration are intercepted. Register a page route before navigation when one page owns the connection. Register a context route before creating pages when popups or several pages can connect. Keep each test in a fresh context unless shared connection state is intentionally under test.

Inspect the Client Protocol Offer

A deterministic route test can prove exactly what the application requests without reaching the real service. It can also send a schema-specific frame so the UI path is exercised. Keep the claim limited to client offer and mocked behavior.

Example 1: Assert the ordered offer in a full socket mock

TypeScript
import { expect, test } from '@playwright/test';

test('offers events v2 before the compatibility protocol', async ({ page }) => {
  let offered: string[] | undefined;

  await page.routeWebSocket('**/realtime/events', socket => {
    offered = socket.protocols();

    if (!offered.includes('events.v2')) {
      void socket.close({ code: 1002, reason: 'events.v2 required' });
      return;
    }

    socket.onMessage(message => {
      const parsed = JSON.parse(message.toString()) as { type?: string };
      if (parsed.type === 'subscribe') {
        socket.send(JSON.stringify({
          type: 'snapshot',
          protocolVersion: 2,
          unread: 3,
        }));
      }
    });
  });

  await page.goto('/notifications');

  await expect.poll(() => offered).toEqual(['events.v2', 'events.v1']);
  await expect(page.getByTestId('unread-count')).toHaveText('3');
  await expect(page.getByRole('status')).toHaveText('Connected');
});

The route is active before navigation, which matters when the application connects during startup. The array assertion checks both membership and order. The UI assertions prove that the version-two snapshot reached the application and changed user-visible state.

This route does not call connectToServer(), so Playwright opens a mocked socket in the page. It does not verify that a deployed server supports events.v2, chooses it, or sends the same snapshot. Name the test accordingly and keep a separate live case.

Keep the mock's protocol behavior deliberately small. It should accept only documented subscription messages, emit frames that satisfy a reviewed schema, and close on an unsupported client action. When the conversation grows to acknowledgements, heartbeats, reconnects, and bidirectional filtering, use the lifecycle patterns in the Playwright WebSocket mocking guide and retain contract fixtures beside the producer schema. Otherwise, a permissive fake can make both protocol versions appear compatible even though the deployed consumers enforce different message rules.

If the client may legitimately offer a single protocol in some deployment, put expected arrays in project configuration instead of weakening the assertion to toContain. An exact list catches accidental removal of a fallback and unreviewed addition of an obsolete protocol.

Prove the Live Server Selection

The negotiation test should use a real upgrade endpoint and inspect WebSocket.protocol only after open. A small browser-side probe is useful when the product does not expose selected protocol in its UI or diagnostics. Keep the endpoint and account controlled, and close the probe promptly.

Example 2: Read the selected protocol from a live browser socket

TypeScript
import { expect, test } from '@playwright/test';

test('the live endpoint selects events.v2', async ({ page }) => {
  await page.goto('/socket-probe');

  const selected = await page.evaluate(async () => {
    const url = new URL('/realtime/events', window.location.href);
    url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';

    return await new Promise<string>((resolve, reject) => {
      const socket = new WebSocket(url, ['events.v2', 'events.v1']);
      const timer = window.setTimeout(() => {
        socket.close();
        reject(new Error('WebSocket open timed out'));
      }, 5_000);

      socket.addEventListener('open', () => {
        window.clearTimeout(timer);
        const protocol = socket.protocol;
        socket.close(1000, 'protocol probe complete');
        resolve(protocol);
      }, { once: true });

      socket.addEventListener('error', () => {
        window.clearTimeout(timer);
        reject(new Error('WebSocket handshake failed'));
      }, { once: true });
    });
  });

  expect(selected).toBe('events.v2');
});

The explicit timer bounds the page-side promise. The assertion reads the selected value before closing. This checks a live service reachable from the browser, including the actual upgrade path through configured proxies or gateways.

Prefer the application's own socket when practical. If the client stores the selected protocol in a connection model and renders a safe diagnostic label, assert that label plus a version-specific message outcome. A separate probe can bypass application authentication, retry policy, and URL construction, so it answers a narrower server question.

Run the live case against a stable test endpoint with known supported versions. It should not depend on production traffic or a random backend. When several server versions are deployed during a rolling release, define the allowed selection set for that window and remove the temporary allowance after rollout.

Design Positive, Negative, and Boundary Cases

Cover the offer, selection, behavior, and failure policy as separate dimensions. A single open event can be green while the wrong schema fails on the next frame.

  1. Offer the preferred and compatibility protocols in documented order.
  2. Verify a live server selects the preferred value when it is supported.
  3. Configure a test server to select the fallback and verify the client uses the matching schema.
  4. Offer no protocols and assert the product's documented empty-selection behavior.
  5. Offer only an unsupported version and verify bounded failure without an infinite reconnect loop.
  6. Deliver a frame whose schema conflicts with the selected protocol and verify safe rejection.
  7. Close with a protocol error and assert user messaging, retry policy, and cleanup.

Use a concrete failure matrix:

ScenarioOfferSelection or closeExpected UIRelease decision
Preferred versionevents.v2, events.v1events.v2Connected and v2 snapshot renderedPass
Compatible fallbackevents.v2, events.v1events.v1Connected with supported fallback statePass if contract allows
No client offerEmptyEmpty or server policy failureExplicit compatibility stateMatch product contract
Unsupported onlyevents.v0Reject or protocol closeNo endless retry; useful errorPass negative case
Server selects unoffered valueevents.v2Handshake failureControlled disconnected stateBlock server deployment
Schema contradicts selectionevents.v2Opens, then v1 frameFrame rejected; page remains stableBlock producer
Route registered lateExpected list unknownExisting socket bypasses routeEvidence missingBlock test infrastructure

Invalid or duplicate protocol tokens may be rejected by the browser constructor before any route is created. Test that client validation at the browser boundary and do not wait for a route handler that cannot run. A trace and page error can help classify this as client setup rather than server rejection.

Keep retries visible. An unsupported protocol that causes rapid reconnects can create many sockets, duplicated subscriptions, and noisy evidence. Count attempts over a controlled interval and use the application's timer abstraction when available instead of sleeping for arbitrary wall time.

Compare Observation, Mocking, Proxying, and Live Tests

Different mechanisms answer different negotiation questions. Choose the smallest one that preserves the dependency under test.

StrategyWhat stays realBest assertionStrengthLimitation
webSocketRoute.protocols() full mockBrowser client offer and UI codeExact offered list plus rendered stateDeterministic edge casesNo deployed server negotiation
connectToServer() proxyBrowser, server, and most trafficOffer, selected product state, selected framesReal handshake with controlled observationForwarding ownership is easy to change
Passive page.on('websocket')Entire live connectionFiltered sent and received framesNo route mutationDoes not expose requested protocol list directly
Browser WebSocket.protocol probeBrowser and live endpointSelected protocol after openDirect selection evidenceMay bypass product connection logic
Protocol-aware test serverBrowser and controlled serverOffer, configured selection, close behaviorPrecise handshake matrixServer fixture must reflect real rules

Use full mocking for rare versions, malformed frames, and UI fallback. Use a live handshake for current deployment compatibility. Use proxying only when one controlled mutation requires the actual service. Installing onMessage() on either proxy direction disables automatic forwarding for that direction, so observation code must explicitly relay accepted frames.

Do not use an HTTP route to fake a WebSocket negotiation. The upgrade and full-duplex lifecycle have different semantics. Likewise, Playwright API testing can validate configuration or token endpoints around the connection, but it cannot prove the browser completed a WebSocket handshake.

The official network documentation describes passive websocket, framesent, framereceived, and close observation. Use those events when the server should remain untouched and message evidence is enough.

Capture Request, Frame, and UI Evidence

Build one correlation record per socket attempt. Include a synthetic attempt ID, sanitized URL path, offered protocols, selected protocol when observed through the page, terminal event, close code, and the first behavior-relevant frame type. Exclude raw authentication values and complete message payloads by default.

Capture listeners before navigation. Filter frames by schema-aware type and safe identifiers, not by dumping every payload. A binary frame should be decoded with the application's approved codec; converting arbitrary bytes to text can create misleading output or leak data in a corrupted form.

The Playwright evidence pipeline can align socket records with screenshots, video, and trace. Current release notes state that HAR and trace recordings include WebSocket requests. Use those artifacts to establish chronology and connection attempts, then rely on socket-specific observation for message claims.

If a HAR is retained, inspect the upgrade entry as connection context rather than replay authority for the full conversation. The HAR network mocking workflow is designed around HTTP request matching and recorded responses. Preserve offered protocols only when policy permits, and never infer a selected protocol or accepted frame schema merely because the socket request appears in the archive.

For a route mock, record whether it was fully mocked or connected to a server. For a proxy, record which directions installed onMessage() and how forwarding was handled. A socket that opens but receives no updates often reflects a swallowed frame after a listener took ownership.

Keep evidence tied to user state. A snapshot frame plus an unchanged counter points to parsing or reducer behavior. No snapshot plus a correct offer points to server, subscription, or forwarding behavior. A mismatch status with no reconnect can be the expected result of an unsupported version.

Protect Protocol and Frame Data

Never place bearer tokens, session IDs, customer IDs, or email addresses in subprotocol names. The offered list crosses infrastructure and may be retained in server, proxy, HAR, or trace records. Use stable version or capability names and keep authentication in the application's approved channel.

Frame data deserves the same care. Subscription messages can contain tenant paths, document IDs, or authorization material. Server events can contain personal content. Attach message type, schema version, length, and a synthetic test identifier when those fields answer the failure. Retain complete payloads only in a restricted diagnostic workflow with synthetic data.

Do not log the full socket URL if its query string is signed. Normalize it to scheme, allowed host class, and pathname. Avoid exposing internal server addresses in public reports. Use a least-privilege test account and a dedicated test channel that cannot subscribe to production data.

If client certificates protect the endpoint, follow the tested Playwright client-certificate setup. Keep private keys outside repository fixtures and reporter attachments. A handshake test should report certificate policy failure without printing certificate material.

Review route code with the AI-generated Playwright evidence checklist. Reject handlers that echo unknown frames, forward secrets to an uncontrolled server, or retain every message. Define artifact access and expiry before enabling socket capture in routine CI.

Debug Missing or Incorrect Negotiation Evidence

Start with the last confirmed lifecycle marker: route installed, constructor called, route matched, socket opened, protocol selected, subscription sent, first valid frame received, UI updated, or socket closed.

SymptomLikely causeFirst checkCorrective action
Route never runsInstalled late or URL mismatchRegistration time and full socket URLRegister before page creation or navigation; fix pattern
protocols() is emptyClient passed no second constructor argumentClient initialization pathFix offer or assert allowed no-protocol behavior
Offer is correct but selection is emptyServer chose none or mock path is being mistaken for live negotiationTest mode and browser protocolRun a live selection probe and check server policy
Socket opens but messages stopProxy handler took forwarding ownershiponMessage() registrationsForward accepted messages explicitly
Browser reports constructor errorInvalid or duplicate tokenPage error before connectionCorrect protocol token generation
Reconnect loop creates many socketsFailure policy lacks terminal stateAttempt count and close reasonBound retries and surface incompatibility
Correct frame, wrong UISchema or reducer mismatchSelected protocol and parsed frame typeFix client version dispatch

URL patterns match the complete socket URL. Include ws or wss, host, path, and query behavior in the diagnosis. If the application falls back to polling, a missing socket may be expected in that environment; assert the fallback explicitly rather than waiting for a connection that is not attempted.

Use the route and Service Worker debugging guide for surrounding HTTP visibility, but remember that WebSocket routing has its own registration and forwarding lifecycle. Use Playwright CLI trace analysis to align constructor errors, navigation, and UI assertions without adding fixed waits.

Do not diagnose selection from offered order alone. Read the live browser protocol or product connection state. Do not diagnose frame loss from a green open event. Inspect subscription, forwarding ownership, close events, and the first expected server message.

Add Deterministic CI Gates

Split CI into a deterministic mocked matrix and a small live compatibility gate. The mocked matrix covers offer order, no-protocol handling, fallback schemas, malformed frames, close behavior, and reconnect limits. The live gate checks that the currently deployed endpoint selects an allowed version and completes one behavior-level exchange.

Example 3: Gate an unsupported offer without retry noise

TypeScript
import { expect, test } from '@playwright/test';

test('surfaces an unsupported realtime protocol once', async ({ page }) => {
  let attempts = 0;
  let offered: string[] | undefined;

  await page.routeWebSocket('**/realtime/events', socket => {
    attempts += 1;
    offered = socket.protocols();
    void socket.close({ code: 1002, reason: 'Unsupported protocol' });
  });

  await page.goto('/legacy-notifications');

  await expect(page.getByRole('alert')).toHaveText(
    'Realtime updates are unavailable for this client version',
  );
  await expect(page.getByTestId('realtime-retry-state')).toHaveText('stopped');

  expect(offered).toEqual(['events.v0']);
  expect(attempts).toBe(1);
});

The test fixture exposes realtime-retry-state only after its reconnect scheduler reaches a terminal state. That deterministic signal closes the observation window before the plain attempt-count assertion runs. Polling until attempts first equals one is insufficient because a delayed second attempt can start after the poll succeeds. If the product cannot expose a test-safe terminal signal, inject an owned retry scheduler or controlled clock rather than adding a fixed sleep.

Apply these release rules:

  1. Block when the client offer removes a required supported version or changes preference without review.
  2. Block when the live server selects an unapproved value or cannot complete the expected handshake.
  3. Block when message schema contradicts the selected protocol.
  4. Block when incompatibility causes an unbounded reconnect loop or hides the product error state.
  5. Fail evidence publication when socket URLs, protocol values, or frame summaries violate security policy.
  6. Report mocked, proxied, and live results separately so one cannot cover another's failure.

Pin the Playwright version and record browser project, endpoint class, selected protocol, and retry index. Keep the live endpoint stable and synthetic. Retain filtered failure evidence, not complete socket transcripts from every passing run.

Run the live compatibility gate in each browser project the product officially supports, because the browser owns constructor validation and exposes the selected value. Keep environment and server revision constant while comparing projects. Feed only safe attempt summaries into the shared Playwright evidence pipeline, so a browser-specific handshake failure can be reviewed without publishing signed URLs or raw subscription frames.

Frequently Asked Questions

What does webSocketRoute.protocols() return?

It returns the WebSocket subprotocols requested by the page through the constructor's second argument, corresponding to the Sec-WebSocket-Protocol request header. It returns an empty array when none were supplied. The method reports the client offer, not a separate assertion that a live server selected one of those values.

How do I assert the negotiated WebSocket subprotocol?

For a live connection, read the browser WebSocket's protocol property after its open event, preferably through a product-visible connection state or a small controlled probe. Assert webSocketRoute.protocols() separately for the offered list. Keeping offer and selection distinct prevents a full mock from impersonating real handshake negotiation.

Should protocol preference order be tested?

Test order when the client contract deliberately ranks compatible versions, because the requested list preserves that intent. Do not assume the server must choose the first value unless your protocol contract says so. Also test the selected value and resulting message schema, since an ordered offer alone does not prove compatibility.

Can routeWebSocket reject an unsupported protocol?

A WebSocket route can inspect protocols() and close a fully mocked socket with a controlled code and reason when the required value is absent. That is useful for client fallback behavior, but it models policy after interception. Keep a live handshake test when you must verify the deployed server's actual HTTP upgrade decision.

Why is my WebSocket route handler never called?

The route may have been registered after the application created its socket, the URL pattern may not match the complete connection URL, or the browser may reject invalid constructor protocols before connecting. Register routing before navigation, capture a sanitized socket URL, and check whether the application selected a polling fallback.

Can HAR or trace prove WebSocket message compatibility?

HAR and trace evidence can help establish the connection request and surrounding chronology, and current Playwright releases include WebSocket requests in those recordings. Use frame events or WebSocketRoute handlers for message-level assertions. A connection artifact alone does not prove schema, ordering, acknowledgement, reconnect, or selected-protocol behavior.

Practice the Full Negotiation Boundary

Start with one realtime feature that supports two protocol versions. Assert the exact client offer in a full mock, drive one version-specific frame into a user-visible result, then run a live browser probe that checks the deployed selection. Add unsupported, empty, and fallback cases with bounded reconnect behavior.

Bring the matrix to QABattle's automation battles and explain which test proves the offer, which proves server choice, and which proves schema handling. The winning design should make a protocol mismatch diagnosable from a safe attempt record without exposing frame content or treating an opened socket as complete evidence.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 18, 2026 / Reviewed July 18, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

What does webSocketRoute.protocols() return?

It returns the WebSocket subprotocols requested by the page through the constructor's second argument, corresponding to the Sec-WebSocket-Protocol request header. It returns an empty array when none were supplied. The method reports the client offer, not a separate assertion that a live server selected one of those values.

How do I assert the negotiated WebSocket subprotocol?

For a live connection, read the browser WebSocket's protocol property after its open event, preferably through a product-visible connection state or a small controlled probe. Assert webSocketRoute.protocols() separately for the offered list. Keeping offer and selection distinct prevents a full mock from impersonating real handshake negotiation.

Should protocol preference order be tested?

Test order when the client contract deliberately ranks compatible versions, because the requested list preserves that intent. Do not assume the server must choose the first value unless your protocol contract says so. Also test the selected value and resulting message schema, since an ordered offer alone does not prove compatibility.

Can routeWebSocket reject an unsupported protocol?

A WebSocket route can inspect protocols() and close a fully mocked socket with a controlled code and reason when the required value is absent. That is useful for client fallback behavior, but it models policy after interception. Keep a live handshake test when you must verify the deployed server's actual HTTP upgrade decision.

Why is my WebSocket route handler never called?

The route may have been registered after the application created its socket, the URL pattern may not match the complete connection URL, or the browser may reject invalid constructor protocols before connecting. Register routing before navigation, capture a sanitized socket URL, and check whether the application selected a polling fallback.

Can HAR or trace prove WebSocket message compatibility?

HAR and trace evidence can help establish the connection request and surrounding chronology, and current Playwright releases include WebSocket requests in those recordings. Use frame events or WebSocketRoute handlers for message-level assertions. A connection artifact alone does not prove schema, ordering, acknowledgement, reconnect, or selected-protocol behavior.