Resource library

QA How-To

Inspect WebSocket Frames with Playwright Step by Step

Learn to playwright inspect websocket frames step by step using current APIs to capture, parse, filter, assert, and safely report real-time traffic today.

17 min read | 2,490 words

TL;DR

Register page.on(websocket) before navigation, attach framesent and framereceived listeners immediately, then parse and filter payloads by message semantics. Use the captured traffic to diagnose failures, but assert the resulting UI behavior for acceptance.

Key Takeaways

  • Register page.on(websocket) before the action that creates the connection.
  • Listen to framesent and framereceived on every matching socket.
  • Treat payloads as string or Buffer and decode them with the real protocol.
  • Create filtered frame promises before triggering fast browser actions.
  • Use frame assertions for protocol evidence and UI assertions for product acceptance.
  • Redact secrets and bound transcripts before attaching diagnostics.

To playwright inspect websocket frames step by step, register a page.on("websocket") listener before the connection opens, then listen for framesent and framereceived on each Playwright WebSocket object. Parse payloads according to their real text or binary format, filter for meaningful messages, and assert the user-visible result.

This tutorial gives you a paste-ready TypeScript project and a repeatable inspection workflow. If you need the broader strategy before focusing on traffic capture, read the Playwright WebSocket testing complete guide.

You will build a small notification page, intercept its socket only to provide deterministic traffic, capture both directions at the browser boundary, wait for one semantic frame without races, and attach sanitized evidence to the Playwright report.

TL;DR

Goal Playwright API Best assertion
Observe a connection page.on('websocket') Expected URL was opened
Inspect traffic framesent and framereceived Parsed frame has required fields
Mock a server page.routeWebSocket() UI reflects the injected message
Read client messages ws.onMessage() Client sends the expected command
Simulate failure ws.close() UI shows offline, then recovers
Catch protocol errors socketerror and close Useful diagnostic is attached

Register listeners and routes before the action that creates the socket, usually before page.goto(). Parse JSON payloads before comparing them. Use Playwright locators and web-first assertions for UI results, and avoid fixed delays.

What You Will Build

By the end of this guide, you will have:

  • A tiny browser application that connects to a WebSocket endpoint and displays connection state, notifications, and acknowledgments.
  • An observation test that records connection URLs and incoming and outgoing frames.
  • A mocked WebSocket test that runs without a live real-time backend.
  • A reconnection test that forces a disconnect and verifies recovery.
  • Reusable helpers for waiting on parsed frames and reporting sanitized diagnostics.

The sample is intentionally small. The same techniques apply to chat, collaborative editing, trading screens, job status monitors, multiplayer features, and any UI driven by persistent bidirectional connections.

Prerequisites

Use Node.js 20 or newer and a current Playwright Test release. Create an empty project and install the runner:

mkdir playwright-ws-guide
cd playwright-ws-guide
npm init -y
npm install -D @playwright/test@latest
npx playwright install chromium

Add these scripts to package.json if you want short commands:

{
  "scripts": {
    "test": "playwright test",
    "test:ws": "playwright test tests/websocket.spec.ts --project=chromium"
  }
}

You should be comfortable with TypeScript, async functions, JSON, locators, and basic WebSocket lifecycle events. You do not need a separate WebSocket package for the mocked tests because Playwright controls the browser connection directly. For deeper test organization, see the Playwright testing complete guide.

Verify the installation with npx playwright --version. Then run npx playwright test --list; an empty project may list no tests, but the command should load without a missing-browser or module error.

Step 1: Set Up Playwright to Inspect WebSocket Frames Step by Step

Create playwright.config.ts so each test starts a local static server. The server command will use a small Node script added in the next step.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 5_000 },
  use: {
    baseURL: 'http://127.0.0.1:4173',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure'
  },
  webServer: {
    command: 'node server.mjs',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
  ]
});

The suite begins with Chromium to keep the tutorial focused. Once the behavior is stable, add Firefox and WebKit projects. A browser matrix is valuable, but it should not multiply nondeterministic backend dependencies. Mocked browser integration tests are a good first cross-browser layer.

Verify the step: run npx playwright test --list. Playwright should read the configuration and start looking under tests. If it reports that server.mjs is missing only when a test runs, continue to Step 2.

Step 2: Build a Page That Opens a WebSocket

Create server.mjs. It serves one HTML document and deliberately points the browser at ws://127.0.0.1:4173/ws. Playwright will intercept that URL, so this tutorial needs no real socket server.

import http from 'node:http';

const html = `<!doctype html>
<html lang="en">
  <body>
    <h1>Notification Center</h1>
    <p data-testid="status">connecting</p>
    <span data-testid="count">0</span>
    <ul data-testid="notifications"></ul>
    <button data-testid="subscribe">Subscribe</button>
    <script>
      const status = document.querySelector('[data-testid=status]');
      const count = document.querySelector('[data-testid=count]');
      const list = document.querySelector('[data-testid=notifications]');
      let socket;
      let retry;
      let seen = new Set();

      function connect() {
        status.textContent = 'connecting';
        socket = new WebSocket('ws://127.0.0.1:4173/ws');
        socket.addEventListener('open', () => {
          status.textContent = 'online';
          socket.send(JSON.stringify({ type: 'hello', client: 'web' }));
        });
        socket.addEventListener('message', event => {
          const message = JSON.parse(event.data);
          if (message.type === 'notification' && !seen.has(message.id)) {
            seen.add(message.id);
            const item = document.createElement('li');
            item.textContent = message.text;
            list.appendChild(item);
            count.textContent = String(seen.size);
          }
          if (message.type === 'subscribed') status.textContent = 'subscribed';
        });
        socket.addEventListener('close', () => {
          status.textContent = 'offline';
          clearTimeout(retry);
          retry = setTimeout(connect, 100);
        });
        socket.addEventListener('error', () => {
          status.textContent = 'error';
        });
      }

      document.querySelector('[data-testid=subscribe]').addEventListener('click', () => {
        socket.send(JSON.stringify({ type: 'subscribe', channel: 'jobs' }));
      });
      connect();
    </script>
  </body>
</html>`;

http.createServer((request, response) => {
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  response.end(html);
}).listen(4173, '127.0.0.1');

This page models the important states: connecting, online, subscribed, offline, and error. It deduplicates notifications by ID, which becomes important when reconnects or server retries replay data. Production code should use capped exponential backoff with jitter instead of a fixed 100 ms retry. The short interval keeps this isolated example fast.

Verify the step: run node server.mjs, visit http://127.0.0.1:4173, and confirm the heading appears. The status will move to offline or error because no real server accepts the WebSocket. Stop the server before Playwright starts it automatically.

Step 3: Playwright Inspect WebSocket Frames Step by Step

Before mocking, learn the observation API. Create tests/websocket.spec.ts with a frame collector. The route supplies deterministic traffic while the page-level listener records what the browser sees.

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

type Frame = { direction: 'sent' | 'received'; payload: string | Buffer };

function observeWebSockets(page: Page) {
  const urls: string[] = [];
  const frames: Frame[] = [];
  const errors: string[] = [];

  page.on('websocket', socket => {
    urls.push(socket.url());
    socket.on('framesent', event => {
      frames.push({ direction: 'sent', payload: event.payload });
    });
    socket.on('framereceived', event => {
      frames.push({ direction: 'received', payload: event.payload });
    });
    socket.on('socketerror', error => errors.push(error));
  });

  return { urls, frames, errors };
}

test('observes the connection and frames', async ({ page }) => {
  const traffic = observeWebSockets(page);

  await page.routeWebSocket('**/ws', ws => {
    ws.onMessage(message => {
      const parsed = JSON.parse(String(message));
      if (parsed.type === 'hello') {
        ws.send(JSON.stringify({ type: 'notification', id: 'n-1', text: 'Build passed' }));
      }
    });
  });

  await page.goto('/');
  await expect(page.getByText('Build passed')).toBeVisible();

  expect(traffic.urls).toContain('ws://127.0.0.1:4173/ws');
  expect(traffic.frames.some(frame => frame.direction === 'sent')).toBe(true);
  expect(traffic.frames.some(frame => frame.direction === 'received')).toBe(true);
  expect(traffic.errors).toEqual([]);
});

page.on('websocket') fires when the page creates a WebSocket. The framesent and framereceived events expose payloads as strings or buffers. Convert deliberately rather than assuming every frame is JSON text. Binary protocols require schema-aware decoding. The socketerror listener is diagnostic evidence, not a replacement for a product assertion.

Register the observer before page.goto(). Otherwise, a socket created during application startup may escape the listener. For a detailed inspection workflow, use inspect Playwright WebSocket frames step by step.

Verify the step: run npm run test:ws. The test should pass, and the page should display Build passed. Temporarily change n-1 to an invalid shape to confirm the visible assertion catches the contract problem.

Step 4: Parse and Wait for a Specific JSON Frame

Direct array assertions can race with asynchronous traffic. Create a promise before the triggering action and resolve it only for the expected frame. Add this helper and test:

import type { WebSocket as PlaywrightWebSocket } from '@playwright/test';

function waitForJsonFrame<T>(
  socket: PlaywrightWebSocket,
  direction: 'framesent' | 'framereceived',
  predicate: (value: T) => boolean
): Promise<T> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('Timed out waiting for frame')), 5_000);
    socket.on(direction, event => {
      try {
        const value = JSON.parse(String(event.payload)) as T;
        if (predicate(value)) {
          clearTimeout(timer);
          resolve(value);
        }
      } catch {
        // Ignore non-JSON frames when this helper targets a JSON protocol.
      }
    });
  });
}

test('sends a channel subscription frame', async ({ page }) => {
  let browserSocket!: PlaywrightWebSocket;
  page.on('websocket', socket => { browserSocket = socket; });
  await page.routeWebSocket('**/ws', () => {});
  await page.goto('/');
  await expect(page.getByTestId('status')).toHaveText('online');

  const subscription = waitForJsonFrame<{ type: string; channel: string }>(
    browserSocket,
    'framesent',
    frame => frame.type === 'subscribe'
  );
  await page.getByTestId('subscribe').click();

  await expect(subscription).resolves.toEqual({ type: 'subscribe', channel: 'jobs' });
});

The promise is created before the click, so a fast frame cannot be missed. The predicate ignores unrelated heartbeat or hello messages. The helper also owns its timeout, producing a targeted error rather than leaving the test pending until the global timeout. In a large suite, include the expected message type in the timeout error.

Verify the step: run the test with --grep "channel subscription". It should pass. Change the predicate to frame.type === 'missing'; the test should fail after about five seconds with Timed out waiting for frame, proving the failure is localized.

Step 5: Connect Frame Evidence to the UI

A transport test becomes useful when it verifies a user outcome. Prefer role, text, label, or stable test ID locators. Inject multiple frames and assert order, count, and content.

test('shows real-time notifications in arrival order', async ({ page }) => {
  await page.routeWebSocket('**/ws', ws => {
    ws.onMessage(message => {
      const parsed = JSON.parse(String(message));
      if (parsed.type === 'hello') {
        ws.send(JSON.stringify({ type: 'notification', id: '1', text: 'Run started' }));
        ws.send(JSON.stringify({ type: 'notification', id: '2', text: 'Run passed' }));
      }
    });
  });

  await page.goto('/');
  const items = page.getByTestId('notifications').getByRole('listitem');

  await expect(items).toHaveCount(2);
  await expect(items).toHaveText(['Run started', 'Run passed']);
  await expect(page.getByTestId('count')).toHaveText('2');
});

toHaveCount, toHaveText, and toContainText retry until the UI reaches the expected state. That makes them safer than reading text once with textContent() and manually comparing it. If the application sorts newest first, encode that product rule in the expected array.

Add checks for unread styling, screen-reader announcements, timestamps, navigation targets, and dismissal only when those are part of the contract. The Playwright real-time notification examples show more UI patterns. For resilient locator design, consult Playwright locator best practices.

Verify the step: run the notification test. It should find exactly two list items in arrival order. Swap the expected strings to verify that the assertion reports a useful ordered diff.

Step 6: Inspect Malformed and Binary Frames Safely

Happy-path JSON is not the whole protocol. Add production-safe handling for invalid payloads, unexpected types, and binary frames, then test the visible fallback. For this sample, capture page errors so malformed JSON is never silently ignored.

test('reports a malformed server frame', async ({ page }) => {
  const pageErrors: string[] = [];
  page.on('pageerror', error => pageErrors.push(error.message));

  await page.routeWebSocket('**/ws', ws => {
    ws.onMessage(message => {
      const parsed = JSON.parse(String(message));
      if (parsed.type === 'hello') ws.send('{not-valid-json');
    });
  });

  await page.goto('/');
  await expect.poll(() => pageErrors.length).toBe(1);
  expect(pageErrors[0]).toContain('JSON');
});

This test documents the current behavior: malformed JSON produces an uncaught page error. A production application should usually catch parsing failures, log a sanitized diagnostic, and keep the stream alive or show a recoverable state. Once that behavior exists, replace the pageerror expectation with the intended UI and telemetry assertions.

For binary frames, event.payload or the routed message can be a Buffer. Do not apply String() unless text decoding is correct for the protocol. Decode with the actual schema, such as Protocol Buffers or a documented byte layout, and keep that decoder in shared production or contract-test code. Avoid inventing a test-only interpretation.

Verify the step: run with --grep "malformed". Expect the test to pass by detecting one parsing error. Change the mock to valid JSON and confirm the polling assertion fails, proving the test is sensitive to malformed content.

Step 7: Attach Sanitized Frame Diagnostics

Frame logs are helpful, but raw payloads may contain authorization data, personal details, or private messages. Sanitize structured payloads and attach a bounded transcript only on failure.

function sanitize(payload: string | Buffer): unknown {
  if (Buffer.isBuffer(payload)) return { binaryBytes: payload.length };
  try {
    const value = JSON.parse(payload);
    if (value.token) value.token = '[redacted]';
    if (value.email) value.email = '[redacted]';
    return value;
  } catch {
    return payload.slice(0, 200);
  }
}

test('attaches sanitized socket traffic', async ({ page }, testInfo) => {
  const transcript: unknown[] = [];
  page.on('websocket', socket => {
    socket.on('framesent', event => transcript.push({ sent: sanitize(event.payload) }));
    socket.on('framereceived', event => transcript.push({ received: sanitize(event.payload) }));
  });

  await page.routeWebSocket('**/ws', ws => {
    ws.onMessage(() => ws.send(JSON.stringify({
      type: 'notification', id: 'safe-1', text: 'Profile updated'
    })));
  });
  await page.goto('/');
  await expect(page.getByText('Profile updated')).toBeVisible();

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

Centralize redaction for your schema. Field-name filtering is only a starting point because secrets can appear in nested structures or unstructured strings. Bound the transcript by frame count and payload length so a busy stream does not exhaust memory or overwhelm a report. Traces, screenshots, console messages, and network evidence together usually explain whether the failure sits in transport, parsing, state, or rendering.

Verify the step: run the test, then open the HTML report with npx playwright show-report. The test attachment should contain sent and received objects, with a maximum of 50 frames. Add a token property locally and confirm the attachment shows [redacted].

Designing a Reliable Test Strategy

Do not put every WebSocket requirement into browser end-to-end tests. Use layers with distinct jobs:

Layer What it proves Typical scope
Protocol or unit Encoding, decoding, reducers, retry policy Many fast cases
Contract Client and server agree on message schemas Each message type
Browser integration with route Frames drive correct application state and UI Main branches and failures
End to end with real backend Deployment, authentication, proxying, and one critical journey work together Few high-value paths

Mocked routes give speed and deterministic edge cases. Real-backend tests reveal proxy configuration, cookies, authentication, TLS, load balancers, and deployment issues that mocks cannot. Keep a small real integration smoke suite and a broader mocked behavior suite. API setup can reduce browser work, as described in Playwright API testing patterns.

Name tests after product behavior, such as restores job subscription after reconnect, rather than implementation details such as websocket test 3. Tag or group them so CI can run mocked tests on every change and real-environment tests where stable infrastructure exists.

Troubleshooting

The route never intercepts the socket -> Register page.routeWebSocket() before page.goto() or before the click that opens the connection. Confirm the glob matches the full WebSocket URL, including its path.

The WebSocket event listener sees no frames -> Attach page.on('websocket') before the socket is created, then attach frame listeners inside that callback immediately. Check whether the application uses WebTransport, server-sent events, or polling instead.

The test passes locally but times out in CI -> Remove arbitrary waits, use locator assertions, and wait for an explicit application state. Confirm the CI proxy permits WebSocket upgrades if the test uses a real backend.

JSON parsing throws for some frames -> Inspect whether the protocol includes heartbeats, plain text, or binary messages. Branch by payload type and message envelope before decoding.

Reconnect creates duplicate messages -> Deduplicate by a stable event ID and test a replayed message after reconnection. Also verify that old subscriptions and listeners are disposed.

The mocked test drifts from production -> Validate fixtures against the owned schema and keep a small real-backend contract or smoke suite. Review protocol changes with both client and service owners.

Common Mistakes and Best Practices

  • Do register observers and routes before the connection starts. Do not add them after navigation and assume startup traffic will wait.
  • Do assert the visible state caused by a frame. Do not stop at counting transport events.
  • Do parse and compare meaningful fields. Do not compare large serialized JSON strings when property order is irrelevant.
  • Do wait for a targeted message or web-first UI state. Do not use waitForTimeout() to guess when the stream is ready.
  • Do cover closure, malformed data, replay, and reconnection. Do not test only the first successful notification.
  • Do preserve one or two real service journeys. Do not assume interception validates authentication, infrastructure, or gateway upgrades.
  • Do redact and bound diagnostics. Do not attach unlimited raw customer traffic to CI artifacts.
  • Do keep message fixtures readable and schema-aligned. Do not bury protocol intent in giant opaque payloads.

Where To Go Next

You now have a focused observer that captures socket URLs, both frame directions, errors, and close events without guessing about timing. Return to the complete Playwright WebSocket testing guide to place this technique in a layered test strategy.

Next, use the Playwright mock WebSocket server responses tutorial when you need deterministic message branches. Continue with testing WebSocket reconnection logic in Playwright for disconnect, retry, and replay cases, then apply the real-time notification assertion examples to badges, ordering, and accessible announcements.

Interview Questions and Answers

Q: How do you inspect WebSocket messages in Playwright?

Listen for the page websocket event, then subscribe to framesent and framereceived on each socket. Parse payloads according to the real protocol and register listeners before the connection begins. Use frame evidence for diagnosis, then assert the resulting application behavior.

Q: How do you mock a WebSocket server in Playwright?

Call page.routeWebSocket() before navigation with a URL pattern and handler. Inside the handler, use ws.onMessage() to read client messages and ws.send() to provide server messages. Use ws.close() when the scenario requires a disconnect.

Q: Why are UI assertions still necessary when frame assertions pass?

A valid frame can be dropped by a parser, reducer, state store, or component. UI assertions prove the user received the intended value. Frame assertions narrow the cause when that outcome fails.

Q: How should a reconnection test avoid flakiness?

Control closure through a mocked route, assert explicit offline and online states, and use retrying assertions such as expect.poll. Avoid narrow elapsed-time assertions and fixed sleeps. Verify restored subscriptions and duplicate protection as separate outcomes.

Q: What can mocked WebSocket tests not prove?

They cannot fully prove production authentication, TLS, reverse-proxy upgrades, load balancer behavior, or service deployment compatibility. Keep a small real-backend smoke or contract layer for those risks.

Q: How do you test binary WebSocket frames?

Treat payloads as buffers and decode them with the real protocol schema. Assert semantic fields after decoding. Never convert arbitrary bytes to text merely to make an assertion convenient.

Q: What is the biggest observability risk in frame logging?

Payloads can expose tokens, personal data, or private content. Redact based on the owned schema, limit stored frames and payload size, and restrict artifact retention and access.

Conclusion

Effective Playwright WebSocket testing combines transport visibility with deterministic control and user-centered assertions. Observe sockets and frames to understand the protocol, use page.routeWebSocket() to exercise hard-to-create states, and prove that the UI handles messages, errors, replays, and reconnects correctly.

Treat each test as evidence for a specific risk. A frame-focused check protects the message contract, a routed scenario protects browser behavior under controlled conditions, and a real-environment smoke test protects the deployment boundary. When a failure occurs, this separation tells you where to investigate first.

Build the mocked happy path first, add disconnection and malformed-data cases, then preserve a small real-backend smoke suite. That layered approach gives fast, explainable tests while still covering the infrastructure that browser mocks cannot reproduce.

Interview Questions and Answers

How does Playwright observe WebSocket traffic?

Playwright emits a websocket event from Page when the browser creates a connection. The resulting WebSocket object emits framesent, framereceived, socketerror, and close events. Listeners must be registered before the connection begins, and payloads must be decoded according to the protocol.

How would you mock WebSocket behavior in a Playwright test?

I would register page.routeWebSocket() before navigation and match only the intended endpoint. In the handler, I would inspect client messages through ws.onMessage(), send schema-valid responses through ws.send(), and close the route for failure scenarios. I would primarily assert the resulting UI state.

How do you design a reliable WebSocket reconnection test?

I control the first connection, close it deliberately, and record subsequent route callbacks. I assert an offline state, eventual recovery, restored subscriptions, and correct processing of replayed events. I avoid fixed sleeps and overly precise timing checks.

What is the difference between frame assertions and UI assertions?

Frame assertions prove that a protocol message crossed the connection with expected content. UI assertions prove that parsing, state management, and rendering produced the user outcome. I use UI assertions for acceptance and frames for targeted contract checks and diagnosis.

What risks remain after all mocked WebSocket tests pass?

Mocked tests do not fully cover authentication handshakes, cookies, TLS, proxy upgrades, load balancers, deployment configuration, or real server schema drift. I retain a small set of real-environment smoke and contract checks for those boundaries.

How do you prevent flaky waits in real-time Playwright tests?

I create event promises before the triggering action and filter for a specific semantic message. For UI behavior, I use Playwright's retrying locator assertions and expect.poll. I avoid waitForTimeout because it guesses instead of observing readiness.

How would you handle WebSocket test diagnostics securely?

I sanitize frames using schema-aware redaction, limit transcript length and payload size, and attach evidence only where needed. Binary data is represented by safe metadata unless decoding is required. Artifact access and retention should follow the sensitivity of the underlying messages.

Frequently Asked Questions

Can Playwright test WebSocket connections?

Yes. Playwright can observe socket creation, sent and received frames, socket errors, and closure. It can also intercept connections with page.routeWebSocket() so tests can emulate server behavior.

How do I inspect WebSocket frames in Playwright?

Register page.on('websocket') before the connection starts. On each socket, listen for framesent and framereceived, then decode each payload according to the application protocol.

Can Playwright mock a WebSocket server without another package?

Yes. Register page.routeWebSocket() before navigation, read browser messages with ws.onMessage(), and send mocked server messages with ws.send(). A separate server is unnecessary for browser integration scenarios.

How do I test WebSocket reconnection in Playwright?

Close the first routed connection with ws.close(), allow the application to retry, and count new route callbacks. Assert the offline and recovered UI states, restored subscriptions, and duplicate-message protection.

Should WebSocket tests assert frames or the UI?

Use both for different purposes. Frame assertions validate protocol details and improve diagnosis, while UI assertions prove the user-visible result and should usually be the primary end-to-end outcome.

Why does my Playwright WebSocket test miss early messages?

The observer or route was probably registered after the application created its socket. Install page listeners and routeWebSocket handlers before page.goto() or before the action that opens the connection.

Does a mocked WebSocket test replace a real backend test?

No. A mock gives deterministic application behavior but does not prove authentication, TLS, proxies, deployment, or server compatibility. Keep a small real-environment smoke or contract suite.

Related Guides