Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 054 / 100

Testing real-time features: a practical Playwright guide

WebSockets and live updates.

Expona Admin8 min read

Testing real-time features: a practical Playwright guide

Real-time features like live notifications, collaborative editing, and instant price updates have become table stakes for modern applications. Testing these features with Playwright requires a different mindset than testing traditional request-response flows—you need to simulate multiple users, observe WebSocket frames, and validate that changes propagate correctly. In this guide, we'll walk through the patterns and techniques we've used across e-commerce, SaaS, and trading platforms to ensure real-time systems work reliably.

Why real-time testing matters

Real-time systems introduce complexity that synchronous tests often miss. When a user updates data, you need to verify not just that their change was saved, but that every connected client receives the update immediately. WebSocket connections can drop, messages can arrive out of order, and race conditions are notoriously hard to reproduce in production. Playwright gives us the tools to simulate these scenarios consistently in a test environment.

The challenge is that traditional UI testing frameworks were built for single-browser workflows. Real-time testing demands we orchestrate multiple browser contexts, inspect low-level protocol frames, and assert state consistency across parallel sessions.

Two browser contexts for two users

The foundation of real-time testing is simulating two independent users. In Playwright, we create separate browser contexts—each with its own cookies, storage, and network—to represent distinct sessions. This isolation ensures that actions in one context don't bleed into another.

Let's walk through a concrete example: testing a collaborative task list where one user creates a task and another user should see it appear instantly.

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

test('task appears in real-time for second user', async ({ browser }) => {
  // Create two independent browser contexts
  const context1 = await browser.newContext();
  const context2 = await browser.newContext();

  const page1 = await context1.newPage();
  const page2 = await context2.newPage();

  // Both users navigate to the app
  await page1.goto('http://localhost:3000/tasks');
  await page2.goto('http://localhost:3000/tasks');

  // Authenticate as different users
  await page1.evaluate(() => {
    localStorage.setItem('userId', 'user-123');
  });
  await page2.evaluate(() => {
    localStorage.setItem('userId', 'user-456');
  });

  // Reload to pick up new user IDs
  await page1.reload();
  await page2.reload();

  // Wait for WebSocket connections to establish
  await page1.waitForLoadState('networkidle');
  await page2.waitForLoadState('networkidle');

  // User 1 creates a new task
  await page1.fill('[data-testid="task-input"]', 'Review pull request');
  await page1.click('[data-testid="create-task-btn"]');

  // Verify User 1 sees the task
  await expect(page1.locator('text=Review pull request')).toBeVisible();

  // User 2 should see the task appear without refreshing
  await expect(page2.locator('text=Review pull request')).toBeVisible();

  await context1.close();
  await context2.close();
});

This test creates two completely independent browser sessions. The key is that both contexts are authenticated as different users, so the backend treats them as separate clients. When user 1 creates a task, the server broadcasts the update via WebSocket, and page 2 receives it without any manual polling.

In production scenarios we've tested, this pattern scales to three or more contexts when you need to validate multi-user interactions like voting, commenting, or concurrent editing.

Assert updates appear in the other context

Real-time systems succeed when updates propagate reliably and consistently. The assertion strategy differs from traditional testing because you're waiting for asynchronous events rather than synchronous responses.

In our experience, the most common pitfall is asserting too quickly before the WebSocket message has been processed by the DOM. We use Playwright's built-in wait utilities to handle this gracefully.

typescript
test('live price update syncs across users', async ({ browser }) => {
  const context1 = await browser.newContext();
  const context2 = await browser.newContext();

  const trader1 = await context1.newPage();
  const trader2 = await context2.newPage();

  await trader1.goto('http://localhost:3000/trading');
  await trader2.goto('http://localhost:3000/trading');

  // Set up different trader IDs
  await trader1.evaluate(() => localStorage.setItem('traderId', 'trader-a'));
  await trader2.evaluate(() => localStorage.setItem('traderId', 'trader-b'));
  await trader1.reload();
  await trader2.reload();

  // Get initial price
  const initialPrice = await trader1.locator('[data-testid="btc-price"]').textContent();

  // Trader 1 updates their bid price
  await trader1.fill('[data-testid="bid-input"]', '45000');
  await trader1.click('[data-testid="submit-bid"]');

  // Trader 2 waits for the order book to update
  // This uses Playwright's locator.waitFor() with timeout
  const orderLocator = trader2.locator('[data-testid="order-item"]:has-text("45000")');
  await expect(orderLocator).toBeVisible({ timeout: 5000 });

  // Verify the price display is correct
  await expect(trader2.locator('[data-testid="bid-price"]')).toContainText('45000');

  await context1.close();
  await context2.close();
});

The critical line here is the timeout on expect(orderLocator).toBeVisible({ timeout: 5000 }). Real-time updates can take a few hundred milliseconds to traverse the network, be processed server-side, and pushed to all connected clients. A 5-second timeout gives your system breathing room without making tests brittle.

When you have strict latency requirements (like a trading platform), you might assert within 500ms or 1000ms. The timeout value should reflect your application's SLA for real-time updates.

page.waitForEvent('websocket') to inspect frames

Sometimes you need to drop down to the protocol level and inspect actual WebSocket frames. This is invaluable for debugging why an update didn't propagate, or validating that your message format is correct.

Playwright allows you to listen for WebSocket events and examine the raw frames being sent.

typescript
test('verify websocket message structure', async ({ page }) => {
  let capturedFrames = [];

  // Listen for WebSocket frames
  page.on('websocket', (ws) => {
    console.log('WebSocket connected:', ws.url());

    ws.on('framesent', (frame) => {
      capturedFrames.push({
        type: 'sent',
        data: frame.payload,
      });
    });

    ws.on('framereceived', (frame) => {
      capturedFrames.push({
        type: 'received',
        data: frame.payload,
      });
    });
  });

  await page.goto('http://localhost:3000/dashboard');
  await page.waitForLoadState('networkidle');

  // Trigger an action that should send a WebSocket message
  await page.click('[data-testid="update-status-btn"]');

  // Wait a bit for frames to arrive
  await page.waitForTimeout(500);

  // Inspect the captured frames
  const sentMessage = capturedFrames.find(f => f.type === 'sent');
  console.log('Sent payload:', sentMessage?.data);

  const receivedMessage = capturedFrames.find(f => f.type === 'received');
  console.log('Received payload:', receivedMessage?.data);

  // Assert message structure
  expect(sentMessage?.data).toContain('update-status');
  expect(receivedMessage?.data).toContain('ack');
});

This is powerful for debugging. We've used it many times to verify that message timestamps are correct, that user IDs are being included, or that our message compression is working. In test environments, you can also validate that sensitive data is being stripped before transmission.

One note: WebSocket frames may be binary or text. If they're binary (common with protobuf or messagepack), you'll need to decode them to inspect the content.

Mock servers for edge cases

In production, you cannot reliably test failure scenarios—network drops, slow servers, message loss—by relying on your staging environment. That's where mock servers come in.

We typically use a combination of Playwright's built-in mocking and tools like Mock Service Worker (MSW) to intercept WebSocket traffic and simulate edge cases.

typescript
test('handles websocket disconnection and reconnects', async ({ page, context }) => {
  // Set up a custom WebSocket interceptor
  await context.addInitScript(() => {
    const originalWebSocket = window.WebSocket;

    window.WebSocket = class MockWebSocket extends originalWebSocket {
      constructor(url) {
        super(url);
        this._isInterrupted = false;
      }

      send(data) {
        if (this._isInterrupted) {
          throw new Error('WebSocket is closed');
        }
        super.send(data);
      }

      simulateDisconnect() {
        this._isInterrupted = true;
        this.close();
      }
    };
  });

  await page.goto('http://localhost:3000/notifications');
  await page.waitForLoadState('networkidle');

  // Verify the connection is open
  await expect(page.locator('[data-testid="connection-status"]'))
    .toContainText('Connected');

  // Simulate a network drop
  await page.evaluate(() => {
    const ws = window.WebSocket.prototype;
    ws.simulateDisconnect();
  });

  // Expect UI to show disconnected state
  await expect(page.locator('[data-testid="connection-status"]'))
    .toContainText('Reconnecting');

  // After reconnection logic kicks in, verify we're back online
  await expect(page.locator('[data-testid="connection-status"]'))
    .toContainText('Connected', { timeout: 5000 });
});

For more sophisticated scenarios, we've used Playwright's route interception to block WebSocket upgrades entirely, forcing the client to test fallback behavior. This is closer to how the application would behave on a slow 3G connection.

Common mistakes

We've seen teams make these errors repeatedly when testing real-time features:

  • Not waiting for WebSocket connection to establish. Calling page.waitForLoadState('networkidle') after navigation ensures the WebSocket handshake is complete before you trigger updates.
  • Using the same browser context for two users. This defeats the purpose—both users will share cookies and storage. Always create separate contexts.
  • Asserting too quickly. Real-time updates take time. Use Playwright's wait methods with appropriate timeouts instead of hard-coded delays.
  • Not cleaning up browser contexts. Forgetting to call context.close() can leak WebSocket connections and cause subsequent tests to hang or fail.
  • Ignoring message ordering. In high-latency networks, messages can arrive out of order. Your assertions should account for this, or you should test message IDs and timestamps.

Key takeaways

  • Create separate browser contexts to simulate independent users; this is the foundation of any real-time test.
  • Use expect().toBeVisible() with appropriate timeouts rather than hard-coded waits—it's more reliable and faster.
  • Inspect WebSocket frames when you need to debug protocol-level issues; Playwright's page.on('websocket') gives you full visibility.
  • Mock servers and network interruptions to test reconnection logic, retries, and graceful degradation.
  • Real-time tests are inherently slower than synchronous tests; budget time in your CI pipeline accordingly.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles