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.
In this guide12 sections
- Define the Subprotocol Contract
- Check Playwright Version and API Semantics
- Inspect the Client Protocol Offer
- Prove the Live Server Selection
- Design Positive, Negative, and Boundary Cases
- Compare Observation, Mocking, Proxying, and Live Tests
- Capture Request, Frame, and UI Evidence
- Protect Protocol and Frame Data
- Debug Missing or Incorrect Negotiation Evidence
- Add Deterministic CI Gates
- Frequently Asked Questions
- What does webSocketRoute.protocols() return?
- How do I assert the negotiated WebSocket subprotocol?
- Should protocol preference order be tested?
- Can routeWebSocket reject an unsupported protocol?
- Why is my WebSocket route handler never called?
- Can HAR or trace prove WebSocket message compatibility?
- 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
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
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.
- Offer the preferred and compatibility protocols in documented order.
- Verify a live server selects the preferred value when it is supported.
- Configure a test server to select the fallback and verify the client uses the matching schema.
- Offer no protocols and assert the product's documented empty-selection behavior.
- Offer only an unsupported version and verify bounded failure without an infinite reconnect loop.
- Deliver a frame whose schema conflicts with the selected protocol and verify safe rejection.
- Close with a protocol error and assert user messaging, retry policy, and cleanup.
Use a concrete failure matrix:
| Scenario | Offer | Selection or close | Expected UI | Release decision |
|---|---|---|---|---|
| Preferred version | events.v2, events.v1 | events.v2 | Connected and v2 snapshot rendered | Pass |
| Compatible fallback | events.v2, events.v1 | events.v1 | Connected with supported fallback state | Pass if contract allows |
| No client offer | Empty | Empty or server policy failure | Explicit compatibility state | Match product contract |
| Unsupported only | events.v0 | Reject or protocol close | No endless retry; useful error | Pass negative case |
| Server selects unoffered value | events.v2 | Handshake failure | Controlled disconnected state | Block server deployment |
| Schema contradicts selection | events.v2 | Opens, then v1 frame | Frame rejected; page remains stable | Block producer |
| Route registered late | Expected list unknown | Existing socket bypasses route | Evidence missing | Block 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.
| Strategy | What stays real | Best assertion | Strength | Limitation |
|---|---|---|---|---|
webSocketRoute.protocols() full mock | Browser client offer and UI code | Exact offered list plus rendered state | Deterministic edge cases | No deployed server negotiation |
connectToServer() proxy | Browser, server, and most traffic | Offer, selected product state, selected frames | Real handshake with controlled observation | Forwarding ownership is easy to change |
Passive page.on('websocket') | Entire live connection | Filtered sent and received frames | No route mutation | Does not expose requested protocol list directly |
Browser WebSocket.protocol probe | Browser and live endpoint | Selected protocol after open | Direct selection evidence | May bypass product connection logic |
| Protocol-aware test server | Browser and controlled server | Offer, configured selection, close behavior | Precise handshake matrix | Server 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.
| Symptom | Likely cause | First check | Corrective action |
|---|---|---|---|
| Route never runs | Installed late or URL mismatch | Registration time and full socket URL | Register before page creation or navigation; fix pattern |
protocols() is empty | Client passed no second constructor argument | Client initialization path | Fix offer or assert allowed no-protocol behavior |
| Offer is correct but selection is empty | Server chose none or mock path is being mistaken for live negotiation | Test mode and browser protocol | Run a live selection probe and check server policy |
| Socket opens but messages stop | Proxy handler took forwarding ownership | onMessage() registrations | Forward accepted messages explicitly |
| Browser reports constructor error | Invalid or duplicate token | Page error before connection | Correct protocol token generation |
| Reconnect loop creates many sockets | Failure policy lacks terminal state | Attempt count and close reason | Bound retries and surface incompatibility |
| Correct frame, wrong UI | Schema or reducer mismatch | Selected protocol and parsed frame type | Fix 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
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:
- Block when the client offer removes a required supported version or changes preference without review.
- Block when the live server selects an unapproved value or cannot complete the expected handshake.
- Block when message schema contradicts the selected protocol.
- Block when incompatibility causes an unbounded reconnect loop or hides the product error state.
- Fail evidence publication when socket URLs, protocol values, or frame summaries violate security policy.
- 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.
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.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official 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.
RELATED GUIDES
Continue the learning route
GUIDE 01
Mock and Inspect WebSocket Traffic in Playwright Tests
Mock and inspect Playwright WebSocket traffic with controlled messages, frame evidence, proxy interception, reconnect tests, and stable UI assertions.
GUIDE 02
Deterministic Network Tests with Playwright HAR Recording and Replay
Record, sanitize, and replay Playwright HAR fixtures for deterministic network tests, strict request matching, safer updates, and clear failures.
GUIDE 03
Playwright Evidence Pipeline for Traces, Video, and Screenshots
Playwright evidence pipeline architecture: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 04
Debug Missing Playwright Route Events Behind Service Workers and Caches
Trace missing Playwright route events through page, service worker, and cache ownership, then choose deterministic interception or worker-aware assertions.
GUIDE 05
Review AI-Generated Playwright Tests with an Evidence Checklist
Master review AI generated Playwright tests with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.