Back to Blog

Testing WebSockets and Real-Time Features: Catch Race Conditions Before They Reach Users

Real-time features — live dashboards, collaborative editing, notifications, and chat — create testing challenges that HTTP-based thinking doesn't handle well. This guide covers testing WebSocket connections, Supabase Realtime channels, Socket.io handlers, and reconnection behavior with Playwright and unit testing strategies.

Published

6 min read

Reading time

Testing WebSockets and Real-Time Features: Catch Race Conditions Before They Reach Users

Real-time features are the norm in modern SaaS applications: Slack-style notifications, live order status updates, collaborative document editing, real-time analytics dashboards. But they introduce a testing dimension that most QA pipelines ignore entirely.

HTTP responses have a clear lifecycle: request, response, done. WebSocket connections are persistent, bidirectional, and stateful. Testing them requires thinking about connection establishment, message delivery ordering, reconnection behavior, and UI reaction to asynchronous events. This guide covers every layer.


The Real-Time Testing Stack

flowchart LR
    A[Client Browser] <-->|WebSocket / SSE| B[Server / Broker]
    B <-->|Pub/Sub| C[Redis / Supabase]
    C <-->|DB Events| D[PostgreSQL]

    A --> E[Tests need to:]
    E --> F[Verify connection establishes]
    E --> G[Assert UI updates on message]
    E --> H[Test reconnection recovery]
    E --> I[Validate message payload schema]

Layer 1: Unit Testing WebSocket Message Handlers

The lowest-friction approach is testing your message handlers in isolation — no actual WebSocket connection required:

// lib/realtime/notifications.ts
export interface NotificationMessage {
  type: 'scan_complete' | 'scan_failed' | 'usage_warning';
  payload: {
    scanId?: string;
    projectId: string;
    message: string;
    severity: 'info' | 'warning' | 'error';
  };
}

export function handleNotificationMessage(
  message: NotificationMessage,
  updateState: (update: Partial<UIState>) => void,
): void {
  switch (message.type) {
    case 'scan_complete':
      updateState({
        scanStatus: 'complete',
        lastScanId: message.payload.scanId,
        notification: { text: message.payload.message, type: 'success' },
      });
      break;
    case 'scan_failed':
      updateState({
        scanStatus: 'failed',
        notification: { text: message.payload.message, type: 'error' },
      });
      break;
    case 'usage_warning':
      updateState({
        showUsageBanner: true,
        notification: { text: message.payload.message, type: 'warning' },
      });
      break;
  }
}
// tests/unit/notification-handler.test.ts
import { describe, it, expect, vi } from 'vitest';
import { handleNotificationMessage } from '../../lib/realtime/notifications';

describe('handleNotificationMessage', () => {
  it('updates scan status on scan_complete', () => {
    const updateState = vi.fn();

    handleNotificationMessage(
      {
        type: 'scan_complete',
        payload: {
          scanId: 'scan-123',
          projectId: 'proj-456',
          message: 'Scan completed with 2 issues',
          severity: 'info',
        },
      },
      updateState,
    );

    expect(updateState).toHaveBeenCalledWith({
      scanStatus: 'complete',
      lastScanId: 'scan-123',
      notification: { text: 'Scan completed with 2 issues', type: 'success' },
    });
  });

  it('marks scan as failed on scan_failed', () => {
    const updateState = vi.fn();

    handleNotificationMessage(
      {
        type: 'scan_failed',
        payload: { projectId: 'proj-456', message: 'Timeout error', severity: 'error' },
      },
      updateState,
    );

    expect(updateState).toHaveBeenCalledWith(expect.objectContaining({ scanStatus: 'failed' }));
  });
});

Layer 2: WebSocket Interception with Playwright

Playwright can intercept and mock WebSocket connections, allowing you to test UI reactions to real-time messages without a running server:

// tests/e2e/realtime-notifications.test.ts
import { test, expect } from '@playwright/test';

test('notification banner appears on scan_complete WebSocket message', async ({ page }) => {
  // Intercept WebSocket connection
  await page.routeWebSocket('wss://app.scanlyapp.com/ws', (ws) => {
    ws.onopen = () => {
      console.log('Test WebSocket connected');
    };

    // Simulate server sending a message after 500ms
    setTimeout(() => {
      ws.send(
        JSON.stringify({
          type: 'scan_complete',
          payload: {
            scanId: 'scan-123',
            projectId: 'proj-456',
            message: 'Scan completed: 0 critical issues found',
            severity: 'info',
          },
        }),
      );
    }, 500);
  });

  await page.goto('/dashboard');
  await page.waitForLoadState('networkidle');

  // Wait for notification to appear (WebSocket message triggers it)
  await expect(page.locator('[data-testid="notification-toast"]')).toBeVisible({ timeout: 3000 });

  await expect(page.locator('[data-testid="notification-toast"]')).toContainText('Scan completed');
});

test('live activity feed updates on new scan entry', async ({ page }) => {
  await page.routeWebSocket('wss://**', (ws) => {
    // Simulate batch of realtime events
    setTimeout(() => {
      ws.send(
        JSON.stringify({
          event: 'INSERT',
          table: 'scan_runs',
          record: {
            id: 'run-789',
            project_id: 'proj-456',
            status: 'complete',
            created_at: new Date().toISOString(),
          },
        }),
      );
    }, 1000);
  });

  await page.goto('/dashboard/activity');

  // Wait for the new entry to appear in the feed
  await expect(page.locator('[data-runid="run-789"]')).toBeVisible({ timeout: 5000 });
});

Layer 3: Supabase Realtime Testing

Supabase Realtime broadcasts PostgreSQL changes over WebSockets. Testing it requires either a real Supabase instance (staging DB) or mocking the Supabase client:

// tests/e2e/supabase-realtime.test.ts

test('scan status updates in real-time via Supabase Realtime', async ({ page, request }) => {
  // Navigate to project detail page
  await page.goto('/projects/test-project-id');
  await page.waitForLoadState('networkidle');

  // Trigger a scan via API (this will cause a DB row update)
  const scanResponse = await request.post('/api/scan/initiate', {
    data: { projectId: 'test-project-id' },
    headers: { Authorization: `Bearer ${process.env.TEST_TOKEN}` },
  });
  expect(scanResponse.status()).toBe(200);

  // The UI should reflect the "scanning" status in real-time
  // without a page refresh
  await expect(page.locator('[data-testid="scan-status"]')).toHaveText('Scanning...', { timeout: 5000 });

  // And eventually "complete"
  await expect(page.locator('[data-testid="scan-status"]')).toHaveText('Complete', { timeout: 30_000 });

  // Verify no page reload occurred (Realtime, not polling)
  const navigationCount = await page.evaluate(() => window.performance.getEntriesByType('navigation').length);
  expect(navigationCount).toBe(1); // Only initial load
});

Layer 4: Reconnection and Error Recovery

Real-time features must handle connection loss gracefully. This is often completely untested:

// tests/e2e/reconnection.test.ts

test('app reconnects and resyncs after WebSocket disconnect', async ({ page, context }) => {
  await page.goto('/dashboard');
  await page.waitForLoadState('networkidle');

  // Verify connected state
  await expect(page.locator('[data-testid="connection-indicator"]')).toHaveClass(/connected/);

  // Simulate network drop (offline)
  await context.setOffline(true);

  // Should show disconnected state
  await expect(page.locator('[data-testid="connection-indicator"]')).toHaveClass(/disconnected/, { timeout: 5000 });

  // Should show a reconnection notice, not a hard error
  await expect(page.locator('[data-testid="reconnecting-banner"]')).toBeVisible();

  // Restore network
  await context.setOffline(false);

  // Should reconnect automatically
  await expect(page.locator('[data-testid="connection-indicator"]')).toHaveClass(/connected/, { timeout: 15_000 });

  // Banner should disappear
  await expect(page.locator('[data-testid="reconnecting-banner"]')).not.toBeVisible();

  // Data should be fresh (synced missed updates)
  // Check that any updates that happened while offline are now visible
});

Testing Server-Sent Events (SSE)

If you use SSE instead of WebSockets, Playwright intercepts these too:

// tests/e2e/sse.test.ts

test('SSE stream receives progress updates during scan', async ({ page }) => {
  const progressUpdates: number[] = [];

  // Intercept SSE endpoint
  await page.route('**/api/scan/progress*', async (route) => {
    // Return SSE stream
    route.fulfill({
      status: 200,
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
      },
      body: [
        'data: {"progress": 25, "message": "Crawling pages..."}\n\n',
        'data: {"progress": 50, "message": "Running checks..."}\n\n',
        'data: {"progress": 100, "message": "Scan complete"}\n\n',
      ].join(''),
    });
  });

  await page.goto('/scan/running');

  // Progress bar should advance
  await expect(page.locator('[data-testid="progress-bar"]')).toHaveAttribute('aria-valuenow', '100', { timeout: 5000 });

  await expect(page.locator('[data-testid="progress-message"]')).toHaveText('Scan complete');
});

Related articles: Also see simulating real-time multi-user interactions in Playwright, webhook retry testing as a complement to WebSocket reliability testing, and API testing foundations that apply across REST, WebSocket, and webhook protocols.


Real-Time Testing Coverage Checklist

Scenario Test Approach Priority
Message handler logic Unit tests (no transport) 🔴 Critical
UI updates on message receipt Playwright WS mock 🔴 Critical
Connection establishment E2E with real backend 🟡 High
Reconnection after disconnect Playwright offline API 🟡 High
Message ordering correctness E2E with message sequencing 🟡 High
Presence indicators E2E multi-context 🟡 High
SSE progress streams Playwright route mock 🟡 High
Authentication on WS upgrade Unit + E2E 🔴 Critical
Rate limiting / message flooding Load test 🟡 High

The most common omission in real-time app testing: no tests for what the UI does when the WebSocket sends an unexpected payload shape. Add schema validation to your consumer handlers and test with malformed payloads.

Make sure your real-time features work correctly on every deploy: Try ScanlyApp free and run automated end-to-end checks that validate your live-update functionality.

Related Posts

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite
Mobile & Cross-Platform
6 min read

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite

Multi-factor authentication is critical for security, but it is notoriously painful to test. OTP codes that expire in 30 seconds, SMS delivery delays, and TOTP clock sync issues create a testing nightmare. Here's how to automate MFA testing without relying on real SMS delivery or manual code entry.