Skip to main content
Back to Blog
Tutorial
2026-07-13

How to Test WebSocket Reconnection in Playwright

Test WebSocket reconnection in Playwright with deterministic server closes, real offline transitions, retry assertions, and duplicate-subscription checks.

How to Test WebSocket Reconnection in Playwright

The price ticker freezes at 10:41:07. The browser still responds, every button still works, and no red error banner appears. Behind that calm screen, the WebSocket died during a network transition and the client never restored its subscription. A happy-path end-to-end test will miss this failure because it usually opens one connection, receives one message, and exits before recovery logic does any work.

WebSocket reconnection deserves its own Playwright scenario. The useful test is not merely whether a second socket appears. It must prove that the interface exposes the interruption, the retry policy activates, the replacement connection authenticates and subscribes correctly, missed state is recovered, and one server event produces one UI update after recovery. Those are separate failure points, and each needs an observable assertion.

This tutorial develops two complementary recipes. The first uses Playwright's page.routeWebSocket() API to create a controlled WebSocket peer and close the first connection on demand. The second uses browserContext.setOffline() against a real test environment to exercise a transport outage. Together they give fast diagnosis without pretending that a protocol close and a broken network are identical.

Define what recovered means for your client

A socket reaching the browser's OPEN state is only the beginning. Most real-time applications perform work above the transport layer: they send a token, negotiate a protocol version, subscribe to channels, restore a cursor, or request a fresh snapshot. A connection can therefore reopen while the screen remains permanently stale.

Write recovery expectations from the user's perspective and then map each one to a lower-level signal. For a collaborative board, the visible success condition might be a remote card update arriving after the outage. For a trading screen, it might be a new sequence number with no duplicated price rows. A chat product may need to reconcile messages sent while the browser was disconnected.

Recovery invariantBrowser-visible evidenceProtocol evidence worth recording
Interruption is represented honestlyStatus changes from connected to reconnecting or offlineOriginal socket emits close or error
Retry actually occursStatus eventually leaves reconnectingA second WebSocket is created
Session is restoredProtected live data resumesReplacement sends auth or resume message
Subscription is restored onceOne new event creates one UI changeExactly one subscribe frame per new connection
Gap handling completesSnapshot or missed items appear in orderCursor, sequence, or resync request is sent
Recovery is boundedUI reaches connected within the agreed budgetAttempt count and delays remain within policy

This matrix prevents a weak assertion such as “connected badge is green” from standing in for the whole behavior. A badge may be driven by onopen even when authentication fails one frame later. Conversely, some robust clients intentionally keep a “syncing” state after transport recovery until the snapshot has been applied. Assert the states your application promises, not a generic WebSocket lifecycle.

Choose the fault that matches the incident

page.routeWebSocket(), available in modern Playwright releases, can fully mock a WebSocket or connect through to a real server. When the handler does not call connectToServer(), the route behaves like the server. Its close(), send(), and onMessage() methods are ideal for a repeatable close at a precise protocol moment.

context.setOffline(true) is a broader disruption. It changes network availability for the browser context, affecting HTTP traffic as well as WebSockets. That is closer to Wi-Fi loss, an airplane-mode transition, or a laptop moving between networks. It is also less surgical, so background requests, telemetry, and refresh-token calls can influence the result.

Injection methodWhat it models wellImportant limitationBest assertion target
WebSocketRoute.close({ code, reason })Server restart, maintenance close, policy closeSends a WebSocket close, not a raw TCP disappearanceClose-code handling and deterministic retry
context.setOffline(true)Device-wide loss of connectivityBreaks every request in that browser contextOffline UX and recovery when connectivity returns
Kill a test WebSocket serverProcess crash or endpoint disappearanceRequires lifecycle control and can slow parallel testsIntegration behavior across proxy and server layers
Network proxy such as ToxiproxyLatency, timeout, reset, and directional link faultsAdds infrastructure and cleanup responsibilitiesTransport resilience under realistic adverse conditions
Close through application test controlsDomain-specific logout, lease expiry, or server drainOnly as faithful as the test hookBusiness behavior tied to a known server event

Do not send close code 1006 from a mock. It is a reserved value used by clients to report abnormal closure and cannot be transmitted in a close frame. For a planned server restart, 1012 is a meaningful choice. For a deployment-specific policy, use the code your service contract defines. If the requirement is an abnormal transport loss, prefer offline emulation, server termination, or a proxy rather than inventing an illegal close frame.

If the application already has HTTP stubbing, keep the WebSocket route separate. Ordinary page.route() handlers deal with HTTP requests and do not control established WebSocket messages. The concepts in this Playwright network interception guide remain useful for bootstrapping API state, but WebSocket frames require the dedicated routing or observation API.

Force the first socket closed with a routed peer

The following test is self-contained. It installs the WebSocket route before creating the page-side socket, hosts a tiny reconnecting client with page.setContent(), closes connection one after the first subscription, and makes connection two deliver a fresh price. The first attempt cannot pass accidentally because the expected value is only sent by the replacement connection.

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

test('resubscribes and renders a fresh price after a server restart', async ({ page }) => {
  let connectionNumber = 0;
  const subscriptions: Array<{ connection: number; channel: string }> = [];

  await page.routeWebSocket('ws://realtime.test/socket', socket => {
    connectionNumber += 1;
    const thisConnection = connectionNumber;

    socket.onMessage(message => {
      const frame = JSON.parse(String(message)) as {
        type: string;
        channel: string;
      };

      if (frame.type !== 'subscribe') return;
      subscriptions.push({ connection: thisConnection, channel: frame.channel });

      if (thisConnection === 1) {
        socket.close({ code: 1012, reason: 'Service restart' });
        return;
      }

      socket.send(
        JSON.stringify({
          type: 'price',
          channel: 'prices:EURUSD',
          value: '1.0874',
          sequence: 902,
        }),
      );
    });
  });

  await page.setContent(`
    <p data-testid="connection-state">starting</p>
    <output data-testid="price">waiting</output>
    <script>
      const state = document.querySelector('[data-testid="connection-state"]');
      const price = document.querySelector('[data-testid="price"]');
      let retry = 0;

      function connect() {
        const socket = new WebSocket('ws://realtime.test/socket');

        socket.addEventListener('open', () => {
          state.textContent = 'connected';
          socket.send(JSON.stringify({
            type: 'subscribe',
            channel: 'prices:EURUSD'
          }));
        });

        socket.addEventListener('message', event => {
          const frame = JSON.parse(event.data);
          if (frame.type === 'price') price.textContent = frame.value;
        });

        socket.addEventListener('close', () => {
          state.textContent = 'reconnecting';
          retry += 1;
          setTimeout(connect, retry * 500);
        });
      }

      connect();
    </script>
  `);

  await expect(page.getByTestId('connection-state')).toHaveText('reconnecting');
  await expect(page.getByTestId('price')).toHaveText('1.0874');
  await expect(page.getByTestId('connection-state')).toHaveText('connected');
  await expect.poll(() => connectionNumber).toBe(2);
  expect(subscriptions).toEqual([
    { connection: 1, channel: 'prices:EURUSD' },
    { connection: 2, channel: 'prices:EURUSD' },
  ]);
});

The route must be registered before setContent() or navigation because Playwright only routes WebSockets created after registration. The captured thisConnection value also matters. Reading the shared counter later inside onMessage() could attribute a delayed frame to the wrong connection.

The sequence of UI assertions is deliberate. The test observes reconnecting before it waits for the new price, proving that the recovery branch was entered. Then it verifies both data and final status. The subscription array confirms that each connection subscribed exactly once. If a bug retains an old reconnect callback and creates a third socket, the exact attempt count exposes it.

Exercise a real transport outage

A routed close proves application logic for a received close event. It does not prove what happens when the browser loses all connectivity. Run a smaller integration suite against a non-production environment where the actual WebSocket server is reachable, then toggle the context offline.

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

test('recovers the live order book after connectivity returns', async ({
  context,
  page,
}) => {
  const sockets: WebSocket[] = [];
  page.on('websocket', socket => {
    if (socket.url().includes('/stream/order-book')) sockets.push(socket);
  });

  await page.goto('/markets/BTC-USD');
  await expect(page.getByTestId('stream-status')).toHaveText('Live');
  await expect.poll(() => sockets.length).toBe(1);

  const sequenceBefore = Number(
    await page.getByTestId('book-sequence').textContent(),
  );

  await context.setOffline(true);
  await expect(page.getByTestId('stream-status')).toHaveText('Reconnecting');
  await expect(page.getByRole('button', { name: 'Place order' })).toBeDisabled();

  await context.setOffline(false);
  await expect(page.getByTestId('stream-status')).toHaveText('Live', {
    timeout: 15_000,
  });
  await expect.poll(() => sockets.length, { timeout: 15_000 }).toBeGreaterThan(1);
  await expect
    .poll(async () => Number(await page.getByTestId('book-sequence').textContent()))
    .toBeGreaterThan(sequenceBefore);
  await expect(page.getByRole('button', { name: 'Place order' })).toBeEnabled();
});

This test uses Playwright's page.on('websocket') event to count browser WebSocket objects. It does not use the count as the only success signal. A new object can exist while the server rejects its token, or while the order book remains stale. The increasing sequence and re-enabled action prove that application-level synchronization completed.

Put the offline reset in a finally block if later assertions or shared fixtures perform cleanup over the network. Playwright creates an isolated context per test by default, yet fixture teardown may still need connectivity. A small helper can make restoration unconditional:

test('shows queued chat messages after a dropped connection', async ({ context, page }) => {
  await page.goto('/rooms/release-team');

  try {
    await context.setOffline(true);
    await page.getByPlaceholder('Write a message').fill('Deploy finished');
    await page.getByRole('button', { name: 'Send' }).click();
    await expect(page.getByText('Deploy finished')).toHaveAttribute(
      'data-delivery',
      'queued',
    );
  } finally {
    await context.setOffline(false);
  }

  await expect(page.getByText('Deploy finished')).toHaveAttribute(
    'data-delivery',
    'sent',
  );
  await expect(page.getByText('Deploy finished')).toHaveCount(1);
});

That last count is not cosmetic. Reconnection frequently exposes idempotency defects: optimistic UI creates one row, replay creates another, and a server echo creates a third. Choose a durable message identifier when possible, not only visible text, especially when users may send identical messages.

Assert backoff without turning the test into a stopwatch

Retry algorithms introduce time. Tests become flaky when they demand that a timer fires at exactly 1,000 milliseconds on a loaded CI worker. They also become slow when production backoff waits 30 seconds between attempts. Separate the policy test from the end-to-end recovery test.

At unit level, inject a scheduler or use controlled time to verify the mathematical sequence and maximum delay. At browser level, assert broad bounds and observable attempt counts. A test-only configuration may shorten the base delay while keeping the multiplier and cap unchanged, provided the production code reads the same validated policy path.

Policy behaviorPrecise test homePlaywright end-to-end check
First retry is immediate or delayedUnit test with controlled timersReconnecting state appears before recovery
Delay grows between failuresUnit or component testAttempt count does not spike in a short observation window
Delay stops growing at the capUnit test over many attemptsUsually omit from the main UI path
Jitter stays inside its rangeProperty or statistical unit test with seeded randomnessNever assert one exact randomized interval
Retry stops after terminal auth failureBrowser integration with a rejected tokenLogin or session-expired UI replaces reconnect loop
Successful connection resets attempt stateUnit plus one repeated outage browser testSecond outage recovers within the initial retry budget

expect.poll() is a good fit for attempt counts and values that change outside a locator. It retries the supplied function until the matcher passes. Locator assertions already retry, so avoid wrapping a normal toHaveText() in manual loops. Never hide recovery behind an unconditional waitForTimeout(); that waits the full duration on fast runs and still fails on a slightly slower worker.

Inspect frames to catch silent resubscription defects

Playwright's observed WebSocket object emits framesent, framereceived, close, and socketerror events. These are diagnostic signals, not a replacement for UI assertions. Capture only what helps explain a failure, and redact credentials before attaching logs.

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

test('sends the last acknowledged cursor on reconnection', async ({ page }, testInfo) => {
  const frames: Array<{ direction: 'sent' | 'received'; payload: string }> = [];

  page.on('websocket', socket => {
    if (!socket.url().includes('/events')) return;

    socket.on('framesent', event => {
      frames.push({ direction: 'sent', payload: String(event.payload) });
    });
    socket.on('framereceived', event => {
      frames.push({ direction: 'received', payload: String(event.payload) });
    });
  });

  await page.goto('/activity');
  await page.getByRole('button', { name: 'Disconnect test socket' }).click();
  await expect(page.getByTestId('activity-sequence')).toHaveText('418');

  const resume = frames
    .filter(frame => frame.direction === 'sent')
    .map(frame => JSON.parse(frame.payload) as Record<string, unknown>)
    .find(frame => frame.type === 'resume');

  expect(resume).toMatchObject({ type: 'resume', afterSequence: 417 });

  await testInfo.attach('websocket-frames.json', {
    body: Buffer.from(JSON.stringify(frames, null, 2)),
    contentType: 'application/json',
  });
});

The example assumes a legitimate test control that disconnects the current server-side session. If your product does not have one, use a routed close or an environment API rather than adding a user-visible button. The key assertion is the cursor: receiving sequence 418 is not enough if the client requested a full replay and wasted bandwidth, or resumed from 410 and duplicated events.

Binary frames need protocol-aware decoding. String(Buffer) may be useful for text but is not a valid parser for MessagePack, Protocol Buffers, compressed payloads, or custom binary envelopes. Decode with the same published schema used by the client, or assert a higher-level UI effect. Avoid dumping binary authentication traffic into CI artifacts.

For broader frame mocking, ordering, and close-code coverage, the WebSocket testing guide provides the protocol-level foundation. In a reconnection test, keep frame inspection focused on recovery: authentication, subscribe, resume cursor, acknowledgement, and duplicated delivery.

Cover the failures that appear after the second connection

One green reconnect scenario is a starting point. The damaging bugs often occur on the third attempt or during a race between timers. Build a compact risk-based set rather than permuting every close code with every browser.

Test a server close immediately after the initial handshake, then another after a confirmed subscription. Those paths exercise different client state. Test a message arriving while the close is being processed, because a stale handler may update state after the new connection becomes authoritative. Test two rapid outages to confirm that a successful recovery resets the backoff counter. Test a terminal authentication response to ensure the client stops retrying and asks the user to sign in.

Also verify cleanup. Each replacement connection should remove listeners, cancel heartbeat timers, and invalidate scheduled retries belonging to the old connection. Symptoms of failed cleanup include duplicate notifications, attempt storms, memory growth, and a status badge oscillating between connected and reconnecting.

Risk-focused scenarioFault timingFailure it can reveal
Server restarts after subscribe acknowledgementStable live sessionMissing resubscription or stale channel list
Link disappears during an outbound commandBefore acknowledgementLost command, unsafe replay, or duplicate execution
Second socket opens before old retry timer firesRecovery raceUnexpected third connection
Token expires while offlineBefore replacement authenticationInfinite retry loop with rejected credentials
Resume cursor is outside server retentionGap request on reconnectFailure to fall back to a fresh snapshot
Browser goes offline twiceSoon after first recoveryBackoff counter or heartbeat not reset

Cross-browser execution is valuable for the offline integration case because browser networking stacks can surface transport failures differently. Keep the fully mocked route test in the main project matrix if it remains fast. If a framework's WebSocket implementation or Playwright support differs by browser version, prefer assertions on the promised UI outcome over a browser-specific error string.

Make the suite deterministic in CI

Parallel workers must not share a channel whose messages can leak between tests. Give each test a unique room, instrument, or tenant identifier, and have the server echo that identifier in events. Clean server state through supported APIs. Waiting for “any update” on a shared feed invites false positives from another worker.

Keep reconnection observability in the application. A stable status element, attempt telemetry in non-production builds, and message sequence IDs improve operations as well as tests. Do not expose secrets or implementation-only controls to production users. Prefer semantic states such as reconnecting, resyncing, and live over a spinner that cannot reveal why it is moving.

When a test fails, retain the Playwright trace, console messages, socket URL without sensitive query parameters, timestamps for socket creation and close, sanitized recovery frames, and the final UI state. The trace alone may show actions but not every frame payload. A small structured attachment usually explains whether the problem was no retry, rejected auth, missing subscribe, or stale rendering.

Finally, assign each layer a clear job. Unit tests prove retry arithmetic and state-machine transitions. Routed Playwright tests prove browser UI behavior against exact close sequences. Offline Playwright tests prove recovery through the deployed stack. Proxy or server-kill tests probe transport edge cases. This split is faster and more trustworthy than forcing one oversized end-to-end test to diagnose every failure mode.

Frequently Asked Questions

Can Playwright terminate an existing WebSocket with page.route()?

No. page.route() handles HTTP routing. Register page.routeWebSocket() before the socket is created, then call WebSocketRoute.close() from the route handler. For a socket that is already connected without routing, Playwright's observed WebSocket object is primarily for inspection. Plan the fault injection before navigation or use an external server or proxy control.

Should a reconnection test assert close code 1006?

Usually not as a universal browser assertion. Code 1006 represents an abnormal closure and cannot be sent in a WebSocket close frame. A browser may report it when connectivity disappears, but exact event details can vary with the failure path. Assert the product's recovery behavior, and use a legal explicit code such as 1012 when testing planned service restart handling.

Why does the WebSocket route never see my application's connection?

The common cause is registration order: only sockets created after routeWebSocket() is installed are routed. Register it before page.goto(). Then check the URL matcher, including scheme, path, base URL resolution, and changing query parameters. Also confirm that the application really uses a browser WebSocket rather than polling or a worker-owned transport outside the expected page path.

How do I test exponential backoff without making Playwright wait for minutes?

Verify the exact delay series with controlled timers in a unit or component test. In Playwright, supply a documented short test policy or assert broad timing bounds and attempt counts with expect.poll(). Retain a less frequent environment test with production timing if the actual long wait and its UX are release-critical.

What proves that reconnection did not duplicate subscriptions?

Capture outbound subscribe frames per connection and assert one expected subscription on each replacement socket. Then publish one uniquely identified server event and assert one resulting UI item. Attempt count alone is insufficient because duplicate listeners on a single new socket can still render the event multiple times.

How to Test WebSocket Reconnection in Playwright | QASkills.sh