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.
