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.

WebSocket frame interception sequence in Playwright The component opens a socket and sends SUBSCRIBE, Playwright's route handler receives the frame on its own timeline, and it returns an ACK that reaches the UI without any real server being contacted. Component Playwright route Route handler open + SUBSCRIBE onMessage() send ACK ACK frame to UI
The frame path Playwright controls: the handler answers every frame, so no real server is contacted.

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 OPEN while 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 afterEach sweep 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 await restores 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.

Grouping the failure modes visually makes the shared root cause obvious: every one of them is a state-management defect that surfaces at the transport seam, and each has a specific, mechanical cure rather than a “add a longer wait” workaround. The map below pairs each vector with the discipline that neutralizes it, and none of those disciplines involves tuning a timeout.

Five flakiness vectors in WebSocket component tests and their cures A central flaky-test node surrounded by five state-management defects — connection desync, listener leakage, message ordering, resource exhaustion, and closure capture — each paired with the mechanical discipline that removes it. Flaky WebSocket test a state defect, not a network fault Connection-state desync cure: gate on a DOM signal Event-listener leakage cure: registry + afterEach sweep Unpredictable ordering cure: await serializes sends Resource exhaustion cure: close every socket Closure capture cure: scope state in beforeEach
Every WebSocket flake reduces to a state defect at the transport seam, each with a mechanical cure.

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. The two modes are not interchangeable: intercept mode is the default for isolated component tests, and proxy mode is a deliberate opt-in reserved for the rare smoke test that must observe a real backend. Choosing intercept and then accidentally letting a frame fall through — by returning early, throwing inside the handler, or never calling ws.send() for a frame the client waits on — silently reintroduces the upstream, which is why the handler must answer or explicitly close every frame it receives.

Intercept versus proxy mode in a routeWebSocket handler The route handler branches into intercept mode, which answers frames locally for deterministic component tests, or proxy mode, which relays to a real upstream for smoke tests and reintroduces timing nondeterminism. Route handler mode Intercept — ws.send() handler answers every frame Proxy — ws.connectToServer() relays to real upstream Isolated + reproducible component tests Real upstream timing smoke tests only default opt-in
Intercept mode keeps component tests deterministic; proxy mode is a deliberate opt-in for real-backend smoke tests.

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. Treat a single flaky run in that matrix as a hard failure to investigate, not a result to re-run away: because every source of nondeterminism has been removed by construction, any divergence points at a concrete teardown or closure-capture defect you can locate rather than an unavoidable timing quirk. That is the payoff of pushing the whole transport under your control — the suite either passes every time or tells you exactly which invariant you broke.

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.