Simulating WebSocket connections in Playwright component tests
Real-time UI — live feeds, notifications, presence indicators — is notoriously hard to test because the transport is asynchronous and the server is out of your control. Pointing component tests at a live WebSocket endpoint introduces handshake timeouts, out-of-order frames, and sockets that linger across workers, all of which surface as intermittent CI failures. This guide is for frontend developers and QA engineers running Playwright 1.48+ component tests against React, Vue, or Svelte components that consume a WebSocket. It shows how to intercept the connection at the browser level with page.routeWebSocket(), deliver frames deterministically, and tear every socket down cleanly so the suite is reproducible. It complements the broader runtime-isolation patterns in DOM & Browser API Mocking.
The diagram below maps the frame path the rest of this guide controls: the browser opens a socket, Playwright intercepts the upgrade, and your route handler — not a real server — decides exactly which frames the component receives and in what order.
Root Cause Analysis
Flaky WebSocket tests almost always stem from asynchronous state mismatches rather than faulty component logic. When the mock responds faster or slower than production, reactive frameworks fire unexpected re-renders or skip lifecycle hooks the test depends on. The instability concentrates in four vectors:
- Connection-state desync. The component reads
OPENwhile mock initialization is still pending, so the first assertion races the handshake. The cure is to wait on a DOM signal that confirms the connected state before asserting anything else. - Event-listener leakage. Handlers registered per test accumulate when sockets are not closed, so frame counts drift upward across iterations. A connection registry plus an
afterEachsweep keeps each test clean. - Unpredictable message ordering. Reactive components fail assertions when frames arrive out of sequence; serializing delivery inside the route handler with explicit
awaitrestores order. - Resource exhaustion. Unclosed mock sockets across parallel workers trigger garbage-collection pauses and degrade runner throughput.
Treating these as state-management problems — not network problems — is what makes the fix durable. Network virtualization must stay decoupled from application state, which is the same principle that governs all Advanced Mocking & Service Isolation Patterns.
There is a fifth vector that is easy to overlook because it only manifests under parallelism: route-handler closure capture. The callback you pass to page.routeWebSocket() closes over whatever variables are in scope when the test file is evaluated. If you declare a shared mutable counter or a messages array at module top level and mutate it inside the handler, two tests running in separate workers will not collide — Playwright isolates workers in separate processes — but two tests in the same file that re-register the route will both see the accumulated state. The defensive pattern is to scope all per-test mutable state inside beforeEach, never at module top level, so each test starts from a fresh registry. The Set<WebSocketRoute> used below is the one exception: it tracks live sockets purely for teardown and is drained in afterEach, so it never feeds an assertion.
A final subtlety concerns the difference between intercepting and proxying. When you call ws.send() inside the handler, the real upstream server is never contacted — Playwright short-circuits the connection entirely. If instead you want to observe traffic against a real backend (a smoke test rather than an isolated component test), you would call ws.connectToServer() and relay frames. For deterministic component tests you almost never want that: a real upstream reintroduces every timing nondeterminism this technique exists to remove. Knowing which mode you are in prevents the most confusing class of failure, where a test “sometimes” reaches the real server because the handler forgot to fully answer the frame.
Reproducible Setup
Install Playwright with component testing enabled and confirm the version, because page.routeWebSocket() landed in 1.48.
npm install -D @playwright/experimental-ct-react@latest
npx playwright --version # must report 1.48 or newer
A minimal component under test connects on mount and renders a status indicator plus a feed:
// src/components/LiveFeed.tsx
import { useEffect, useState } from 'react';
export function LiveFeed() {
const [status, setStatus] = useState('Connecting');
const [feed, setFeed] = useState('');
useEffect(() => {
const ws = new WebSocket('wss://app.internal.test/api/v1/ws/stream');
ws.onopen = () => setStatus('Connected');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'ACK') setFeed('Live Feed Active');
};
return () => ws.close();
}, []);
return (
<div>
<span data-testid="ws-indicator">{status}</span>
<button data-testid="subscribe-btn" onClick={() => {}}>Subscribe</button>
<div data-testid="data-feed">{feed}</div>
</div>
);
}
Implementation
Deterministic stubbing means intercepting both inbound and outbound frames so bidirectional state stays consistent. Routing through Playwright’s native API lets you assert exact payload shapes without an external proxy. Aligning these browser-level controls with the conventions in DOM & Browser API Mocking prevents state desync during rapid UI updates.
Step 1: Register the route with strict URL matching and a connection registry.
import { test, expect } from '@playwright/test';
import type { WebSocketRoute } from '@playwright/test';
const activeConnections = new Set<WebSocketRoute>();
test.beforeEach(async ({ page }) => {
await page.routeWebSocket('**/api/v1/ws/stream', (ws) => {
activeConnections.add(ws);
ws.onMessage((message) => {
try {
const payload = JSON.parse(
typeof message === 'string' ? message : message.toString(),
);
if (payload.type === 'SUBSCRIBE' && typeof payload.channel === 'string') {
ws.send(JSON.stringify({ type: 'ACK', status: 'subscribed', channel: payload.channel }));
} else {
ws.send(JSON.stringify({ type: 'ERROR', code: 'INVALID_SCHEMA' }));
}
} catch {
ws.send(JSON.stringify({ type: 'ERROR', code: 'PARSE_FAILURE' }));
}
});
ws.onClose(() => activeConnections.delete(ws));
});
});
Step 2: Enforce teardown of every registered socket.
test.afterEach(async () => {
for (const conn of activeConnections) {
await conn.close();
}
activeConnections.clear();
});
Step 3: Assert on the connected state before driving the interaction.
test('deterministic WebSocket message flow', async ({ page }) => {
await page.goto('/dashboard');
// Gate on a DOM signal, not a timer, to eliminate handshake races
await expect(page.locator('[data-testid="ws-indicator"]')).toHaveText('Connected');
await page.click('[data-testid="subscribe-btn"]');
// UI updates only after the mock ACK frame is delivered
await expect(page.locator('[data-testid="data-feed"]')).toContainText('Live Feed Active');
});
The route handler serializes outbound replies, so the ACK always lands before the assertion runs — there is no implicit latency to tune.
Step 4: Drive a server-push scenario with an ordered frame sequence. Many real-time UIs do not request data; the server pushes it. To test an unsolicited stream of updates, send frames directly after the connection opens and assert that the component renders each in order. Because the sends are serialized with await, the component’s reactive batching cannot reorder them.
test('renders pushed frames in order', async ({ page }) => {
const frames = [
{ type: 'TICK', seq: 1, price: 100 },
{ type: 'TICK', seq: 2, price: 101 },
{ type: 'TICK', seq: 3, price: 102 },
];
await page.routeWebSocket('**/api/v1/ws/stream', async (ws) => {
activeConnections.add(ws);
for (const frame of frames) {
ws.send(JSON.stringify(frame));
}
ws.onClose(() => activeConnections.delete(ws));
});
await page.goto('/ticker');
await expect(page.locator('[data-testid="last-price"]')).toHaveText('102');
await expect(page.locator('[data-testid="tick-count"]')).toHaveText('3');
});
Step 5: Model a typed protocol so payload bugs fail at compile time. Hand-writing JSON.stringify calls invites schema drift between the mock and production. Declare the wire contract once and let TypeScript reject malformed frames inside the handler — the same discipline you would apply to any HTTP request stubbing technique.
type ClientFrame =
| { type: 'SUBSCRIBE'; channel: string }
| { type: 'UNSUBSCRIBE'; channel: string };
type ServerFrame =
| { type: 'ACK'; status: 'subscribed'; channel: string }
| { type: 'ERROR'; code: 'INVALID_SCHEMA' | 'PARSE_FAILURE' };
function reply(ws: WebSocketRoute, frame: ServerFrame): void {
ws.send(JSON.stringify(frame));
}
await page.routeWebSocket('**/api/v1/ws/stream', (ws) => {
ws.onMessage((raw) => {
let frame: ClientFrame;
try {
frame = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
} catch {
return reply(ws, { type: 'ERROR', code: 'PARSE_FAILURE' });
}
if (frame.type === 'SUBSCRIBE') {
reply(ws, { type: 'ACK', status: 'subscribed', channel: frame.channel });
}
});
});
Because reply only accepts a ServerFrame, a typo such as status: 'subscribe' is caught by the compiler instead of surfacing as a runtime assertion failure that is far harder to trace.
Verification
A correct run produces a clean Playwright report with no socket warnings and stable timing across repeats:
Running 1 test using 1 worker
✓ live-feed.spec.ts:18 › deterministic WebSocket message flow (412ms)
1 passed (1.1s)
Run the spec with --repeat-each=10 and confirm identical output on every pass; a single divergent run signals a teardown gap. To prove there are no lingering sockets, assert the registry empties after teardown:
test('registry is drained after teardown', async () => {
expect(activeConnections.size).toBe(0);
});
To catch ordering regressions specifically, capture the rendered values into an array as they appear and assert the full sequence rather than only the final state. A test that asserts only the last value will pass even when intermediate frames were dropped or reordered, masking exactly the bug this technique guards against:
test('every pushed frame reaches the DOM in order', async ({ page }) => {
await page.goto('/ticker');
const rendered = await page
.locator('[data-testid="tick-log"] li')
.allTextContents();
expect(rendered).toEqual(['100', '101', '102']);
});
For the strongest signal in CI, combine --repeat-each=10 with --workers=4. Parallelism is where closure-capture and teardown bugs reveal themselves, because the same file may be re-evaluated and the route re-registered. A suite that is green across all forty runs has demonstrated both determinism and isolation, which is the bar these tests must clear before they earn a place in the merge gate alongside the rest of your Playwright component testing suite.
Troubleshooting
page.routeWebSocket is not a function. You are on Playwright older than 1.48. Diagnosis: the method is simply absent on the page object. Fix: upgrade Playwright, or fall back to intercepting the upgrade request with page.route() and serving a mock from a webServer entry until you can.
The indicator never reads Connected. The URL glob did not match, so the real socket attempt fell through. Diagnosis: add a console.log inside the route callback and confirm it fires. Fix: widen or correct the pattern (**/api/v1/ws/stream) and ensure it is registered in beforeEach, before navigation.
Frame counts grow across the run. Sockets are not closing between tests. Diagnosis: log activeConnections.size in afterEach; a nonzero value confirms leakage. Fix: ensure every handler calls ws.onClose(() => activeConnections.delete(ws)) and that the afterEach sweep awaits each close().
Binary frames arrive as [object Object]. The handler stringified an ArrayBuffer or Blob instead of decoding it. Diagnosis: log typeof message inside onMessage; a non-string value means the client sent binary. Fix: branch on the type — typeof message === 'string' ? message : new TextDecoder().decode(message) — before parsing, and send binary replies with a Uint8Array rather than a JSON string when the protocol expects them.
The component reconnects in a loop and the test times out. Your handler closed the socket with a clean code (1000), so the client’s reconnection logic treated it as transient and retried, registering a fresh socket each cycle. Diagnosis: watch activeConnections.size climb during a single test. Fix: when you want the client to stop, send an application-level shutdown frame the component recognizes, or assert the reconnect attempt explicitly and then end the test rather than leaving the loop running.
Two tests in one file interfere. A shared mutable variable declared at module top level leaked state between tests. Diagnosis: the second test fails only when run after the first. Fix: move all per-test state into beforeEach so each test re-initializes it, as described in the Root Cause section.
FAQ
Does this work with Vitest browser mode instead of Playwright?
Not with the same API. page.routeWebSocket() is a Playwright primitive tied to the Playwright page object, so Vitest’s browser mode cannot call it. For Vitest, stub the global WebSocket constructor with a controllable mock class instead, as shown in DOM & Browser API Mocking; the deterministic-frame principle is identical, only the interception layer differs.
How do I simulate an abnormal disconnect to test reconnection logic?
Call ws.close(1006) from inside the route handler — or simply do not send the expected frame and let the client’s timeout fire. Triggering a non-clean close code lets you assert that the component retries or surfaces an error banner, which is the behaviour most reconnection bugs hide in.
Why does the test pass locally but fail in CI?
CI runners are slower, so a timer-based wait that was generous locally becomes too tight under load. Replace every fixed waitForTimeout with a state assertion such as toHaveText('Connected'), and align the suite timeout with your mock latency profile so a slow handshake is not misread as a failure.
Should I assert on raw frame payloads or on rendered UI?
Prefer rendered UI. Asserting that the feed shows “Live Feed Active” validates the whole reactive path, whereas asserting on raw frames couples the test to serialization details. Reserve payload-level assertions for protocol contracts where the exact wire shape is the thing under test.
Can I delay a frame to test a loading spinner?
Yes, but delay deterministically rather than with a wall-clock pause. Await a controllable barrier inside the handler — for example a promise you resolve from the test body after asserting the spinner is visible — instead of setTimeout. This keeps the loading-state window under your control: you assert the spinner, then release the frame, then assert the loaded state, with no race and no fixed timeout to tune across CI machines.
How do I verify the client sent the correct outbound frame?
Capture inbound frames inside onMessage into a per-test array declared in beforeEach, then assert on it after driving the interaction. Because the array is recreated each test it cannot leak, and asserting expect(received).toEqual([{ type: 'SUBSCRIBE', channel: 'prices' }]) confirms the component serialized its request correctly without coupling to the reply path.
Related
- Back to DOM & Browser API Mocking
- Mocking IntersectionObserver and ResizeObserver in jsdom — the in-process counterpart for observer APIs.
- Playwright component testing — the framework foundation these tests build on.
- Advanced Mocking & Service Isolation Patterns — the principles behind decoupling network virtualization from state.