Qaskills WebSocket Bug Finder
Test WebSocket connections for reliability including reconnection logic, message ordering, heartbeat mechanisms, and connection state management under adverse conditions
git clone https://github.com/PramodDutta/qaskills
T=$(mktemp -d) && git clone --depth=1 https://github.com/PramodDutta/qaskills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/seed-skills/websocket-bug-finder" ~/.claude/skills/pramoddutta-qaskills-websocket-bug-finder && rm -rf "$T"
seed-skills/websocket-bug-finder/SKILL.mdWebSocket Bug Finder Skill
You are an expert QA automation engineer specializing in WebSocket and real-time communication testing. When the user asks you to write, review, or debug WebSocket tests, follow these detailed instructions to validate connection lifecycle management, reconnection reliability, message ordering guarantees, heartbeat mechanisms, and connection behavior under adverse network conditions.
Core Principles
- Connections are ephemeral -- WebSocket connections will drop unexpectedly due to network changes, server restarts, load balancer timeouts, and mobile device sleep. Every WebSocket client must handle disconnection gracefully and reconnect automatically.
- Message ordering is not guaranteed without explicit sequencing -- While TCP guarantees in-order delivery within a single connection, reconnection creates a new TCP stream. Messages sent during reconnection may arrive out of order. Test that applications handle sequence gaps correctly.
- Heartbeats are a contract -- Both client and server must participate in keep-alive mechanisms. Test that heartbeats are sent at the correct interval, that missed heartbeats trigger reconnection, and that heartbeat failures do not cause silent connection death.
- Test the transitions, not just the states -- The critical bugs live in state transitions: connecting to open, open to closing, closing to closed, closed to reconnecting. Test every transition path, especially the error paths.
- Simulate real network conditions -- Lab environments with perfect connectivity will never expose reconnection bugs. Use network throttling, packet loss simulation, and connection interruption to test under realistic conditions.
- Binary and text frames have different semantics -- WebSocket supports both text frames (UTF-8 encoded) and binary frames (ArrayBuffer). Test that the application correctly handles both frame types and does not confuse them.
- Concurrency limits matter -- Browsers limit WebSocket connections per domain (typically 6-30). Test that the application functions correctly near these limits and handles connection pool exhaustion gracefully.
Project Structure
Organize WebSocket testing projects with this structure:
tests/ websocket/ connection/ lifecycle.spec.ts reconnection.spec.ts authentication.spec.ts concurrent-connections.spec.ts messaging/ ordering.spec.ts delivery-guarantee.spec.ts binary-messages.spec.ts large-payloads.spec.ts heartbeat/ ping-pong.spec.ts timeout-detection.spec.ts keep-alive.spec.ts resilience/ network-disruption.spec.ts server-restart.spec.ts backpressure.spec.ts e2e/ real-time-updates.spec.ts collaborative-editing.spec.ts chat-messaging.spec.ts helpers/ ws-test-client.ts ws-message-recorder.ts network-simulator.ts ws-server-mock.ts fixtures/ websocket.fixture.ts config/ ws-test-config.ts playwright.config.ts
WebSocket Connection Lifecycle Testing
Test Client Helper
// helpers/ws-test-client.ts import WebSocket from 'ws'; import { EventEmitter } from 'events'; interface WSMessage { data: string | Buffer; timestamp: number; type: 'text' | 'binary'; sequence?: number; } interface ConnectionEvent { type: 'open' | 'close' | 'error' | 'message' | 'reconnect'; timestamp: number; details?: unknown; } export class WSTestClient extends EventEmitter { private ws: WebSocket | null = null; private url: string; private messages: WSMessage[] = []; private events: ConnectionEvent[] = []; private reconnectAttempts = 0; private maxReconnectAttempts = 5; private reconnectDelay = 1000; private shouldReconnect = true; private heartbeatInterval: NodeJS.Timeout | null = null; private heartbeatTimeout: NodeJS.Timeout | null = null; private headers: Record<string, string>; constructor( url: string, options: { headers?: Record<string, string>; maxReconnectAttempts?: number; reconnectDelay?: number; } = {} ) { super(); this.url = url; this.headers = options.headers || {}; this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5; this.reconnectDelay = options.reconnectDelay ?? 1000; } connect(): Promise<void> { return new Promise((resolve, reject) => { this.ws = new WebSocket(this.url, { headers: this.headers }); this.ws.on('open', () => { this.reconnectAttempts = 0; this.recordEvent('open'); this.emit('open'); resolve(); }); this.ws.on('message', (data: WebSocket.Data) => { const message: WSMessage = { data: data instanceof Buffer ? data : data.toString(), timestamp: Date.now(), type: data instanceof Buffer ? 'binary' : 'text', }; // Extract sequence number if present if (typeof message.data === 'string') { try { const parsed = JSON.parse(message.data); if (parsed.seq !== undefined) { message.sequence = parsed.seq; } } catch { // Not JSON, that is fine } } this.messages.push(message); this.recordEvent('message', message); this.emit('message', message); }); this.ws.on('close', (code, reason) => { this.recordEvent('close', { code, reason: reason.toString() }); this.emit('close', code, reason); this.stopHeartbeat(); if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { this.attemptReconnect(); } }); this.ws.on('error', (error) => { this.recordEvent('error', { message: error.message }); this.emit('error', error); reject(error); }); }); } private async attemptReconnect(): Promise<void> { this.reconnectAttempts++; const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); this.recordEvent('reconnect', { attempt: this.reconnectAttempts, delay }); await new Promise((resolve) => setTimeout(resolve, delay)); try { await this.connect(); } catch { // Reconnect failed, will retry if under max attempts } } send(data: string | Buffer): void { if (this.ws?.readyState !== WebSocket.OPEN) { throw new Error(`Cannot send: WebSocket is ${this.getStateName()}`); } this.ws.send(data); } startHeartbeat(intervalMs: number = 30000, timeoutMs: number = 5000): void { this.heartbeatInterval = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.ping(); this.heartbeatTimeout = setTimeout(() => { // No pong received within timeout this.emit('heartbeat-timeout'); this.ws?.terminate(); }, timeoutMs); } }, intervalMs); this.ws?.on('pong', () => { if (this.heartbeatTimeout) { clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = null; } this.emit('pong'); }); } stopHeartbeat(): void { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } if (this.heartbeatTimeout) { clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = null; } } close(code: number = 1000, reason: string = 'Test complete'): void { this.shouldReconnect = false; this.stopHeartbeat(); this.ws?.close(code, reason); } getMessages(): WSMessage[] { return [...this.messages]; } getEvents(): ConnectionEvent[] { return [...this.events]; } getState(): number { return this.ws?.readyState ?? WebSocket.CLOSED; } getStateName(): string { const states: Record<number, string> = { [WebSocket.CONNECTING]: 'CONNECTING', [WebSocket.OPEN]: 'OPEN', [WebSocket.CLOSING]: 'CLOSING', [WebSocket.CLOSED]: 'CLOSED', }; return states[this.getState()] || 'UNKNOWN'; } getReconnectAttempts(): number { return this.reconnectAttempts; } clearMessages(): void { this.messages = []; } private recordEvent(type: ConnectionEvent['type'], details?: unknown): void { this.events.push({ type, timestamp: Date.now(), details }); } }
Connection Lifecycle Tests
// tests/websocket/connection/lifecycle.spec.ts import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WSTestClient } from '../../helpers/ws-test-client'; import { WebSocketServer, WebSocket } from 'ws'; let wss: WebSocketServer; const PORT = 8765; const WS_URL = `ws://localhost:${PORT}`; beforeAll(() => { wss = new WebSocketServer({ port: PORT }); wss.on('connection', (ws) => { ws.on('message', (data) => { // Echo messages back ws.send(data); }); }); }); afterAll(() => { wss.close(); }); describe('WebSocket Connection Lifecycle', () => { let client: WSTestClient; afterEach(() => { client?.close(); }); test('should establish connection and reach OPEN state', async () => { client = new WSTestClient(WS_URL); await client.connect(); expect(client.getState()).toBe(WebSocket.OPEN); expect(client.getStateName()).toBe('OPEN'); const events = client.getEvents(); expect(events[0].type).toBe('open'); }); test('should perform clean close with code 1000', async () => { client = new WSTestClient(WS_URL); await client.connect(); const closePromise = new Promise<{ code: number; reason: Buffer }>((resolve) => { client.on('close', (code, reason) => resolve({ code, reason })); }); client.close(1000, 'Normal closure'); const result = await closePromise; expect(result.code).toBe(1000); expect(client.getState()).toBe(WebSocket.CLOSED); }); test('should record all lifecycle events in order', async () => { client = new WSTestClient(WS_URL); await client.connect(); client.send('test message'); // Wait for echo await new Promise<void>((resolve) => { client.on('message', () => resolve()); }); client.close(); await new Promise<void>((resolve) => { client.on('close', () => resolve()); }); const events = client.getEvents(); const eventTypes = events.map((e) => e.type); expect(eventTypes[0]).toBe('open'); expect(eventTypes).toContain('message'); expect(eventTypes[eventTypes.length - 1]).toBe('close'); // Events should be in chronological order for (let i = 1; i < events.length; i++) { expect(events[i].timestamp).toBeGreaterThanOrEqual(events[i - 1].timestamp); } }); test('should handle server-initiated close', async () => { client = new WSTestClient(WS_URL, { maxReconnectAttempts: 0 }); await client.connect(); const closePromise = new Promise<number>((resolve) => { client.on('close', (code) => resolve(code)); }); // Close from server side wss.clients.forEach((ws) => { ws.close(1001, 'Server going away'); }); const code = await closePromise; expect(code).toBe(1001); }); test('should reject connection to invalid URL', async () => { client = new WSTestClient('ws://localhost:9999', { maxReconnectAttempts: 0 }); await expect(client.connect()).rejects.toThrow(); expect(client.getState()).toBe(WebSocket.CLOSED); }); });
Reconnection Logic Verification
// tests/websocket/connection/reconnection.spec.ts import { describe, test, expect, afterEach } from 'vitest'; import { WSTestClient } from '../../helpers/ws-test-client'; import { WebSocketServer, WebSocket } from 'ws'; describe('WebSocket Reconnection', () => { let wss: WebSocketServer; let client: WSTestClient; const PORT = 8766; afterEach(() => { client?.close(); wss?.close(); }); test('should automatically reconnect after server disconnect', async () => { wss = new WebSocketServer({ port: PORT }); client = new WSTestClient(`ws://localhost:${PORT}`, { maxReconnectAttempts: 3, reconnectDelay: 100, }); await client.connect(); expect(client.getState()).toBe(WebSocket.OPEN); // Track reconnection const reconnectPromise = new Promise<void>((resolve) => { client.on('open', () => resolve()); }); // Force disconnect from server side wss.clients.forEach((ws) => ws.terminate()); // Wait for reconnection await reconnectPromise; expect(client.getState()).toBe(WebSocket.OPEN); expect(client.getReconnectAttempts()).toBeGreaterThanOrEqual(1); }); test('should use exponential backoff for reconnection attempts', async () => { // Start server, connect, then stop server to force reconnect failures wss = new WebSocketServer({ port: PORT }); client = new WSTestClient(`ws://localhost:${PORT}`, { maxReconnectAttempts: 4, reconnectDelay: 100, }); await client.connect(); // Close server to prevent reconnection wss.close(); // Force disconnect client['ws']?.terminate(); // Wait for all reconnection attempts to fail await new Promise((resolve) => setTimeout(resolve, 5000)); const events = client.getEvents().filter((e) => e.type === 'reconnect'); // Verify exponential backoff pattern for (let i = 1; i < events.length; i++) { const prevDelay = (events[i - 1].details as { delay: number }).delay; const currDelay = (events[i].details as { delay: number }).delay; expect(currDelay).toBeGreaterThanOrEqual(prevDelay); } }); test('should stop reconnecting after max attempts', async () => { const maxAttempts = 3; wss = new WebSocketServer({ port: PORT }); client = new WSTestClient(`ws://localhost:${PORT}`, { maxReconnectAttempts: maxAttempts, reconnectDelay: 50, }); await client.connect(); wss.close(); client['ws']?.terminate(); // Wait for all attempts to exhaust await new Promise((resolve) => setTimeout(resolve, 3000)); const reconnectEvents = client.getEvents().filter((e) => e.type === 'reconnect'); expect(reconnectEvents.length).toBeLessThanOrEqual(maxAttempts); }); test('should reset reconnect counter after successful connection', async () => { wss = new WebSocketServer({ port: PORT }); client = new WSTestClient(`ws://localhost:${PORT}`, { maxReconnectAttempts: 5, reconnectDelay: 100, }); await client.connect(); // First disconnect and reconnect const firstReconnect = new Promise<void>((resolve) => { client.once('open', () => resolve()); }); wss.clients.forEach((ws) => ws.terminate()); await firstReconnect; // After successful reconnect, counter should reset expect(client.getReconnectAttempts()).toBe(0); // Second disconnect and reconnect should also work const secondReconnect = new Promise<void>((resolve) => { client.once('open', () => resolve()); }); wss.clients.forEach((ws) => ws.terminate()); await secondReconnect; expect(client.getState()).toBe(WebSocket.OPEN); }); });
Message Ordering and Delivery Guarantees
// tests/websocket/messaging/ordering.spec.ts import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WSTestClient } from '../../helpers/ws-test-client'; import { WebSocketServer } from 'ws'; describe('Message Ordering', () => { let wss: WebSocketServer; let client: WSTestClient; const PORT = 8767; beforeAll(() => { wss = new WebSocketServer({ port: PORT }); wss.on('connection', (ws) => { ws.on('message', (data) => { // Echo with server timestamp const msg = JSON.parse(data.toString()); ws.send( JSON.stringify({ ...msg, serverTimestamp: Date.now(), }) ); }); }); }); afterAll(() => wss.close()); afterEach(() => client?.close()); test('messages should arrive in order within a single connection', async () => { client = new WSTestClient(`ws://localhost:${PORT}`); await client.connect(); const messageCount = 100; const receivedPromise = new Promise<void>((resolve) => { let count = 0; client.on('message', () => { count++; if (count === messageCount) resolve(); }); }); // Send 100 messages with sequence numbers for (let i = 0; i < messageCount; i++) { client.send(JSON.stringify({ seq: i, data: `message-${i}` })); } await receivedPromise; const messages = client.getMessages(); expect(messages).toHaveLength(messageCount); // Verify ordering for (let i = 0; i < messages.length; i++) { const parsed = JSON.parse(messages[i].data as string); expect(parsed.seq).toBe(i); } }); test('should detect out-of-order messages', async () => { client = new WSTestClient(`ws://localhost:${PORT}`); await client.connect(); // Create a server that deliberately reorders messages const reorderWss = new WebSocketServer({ port: PORT + 1 }); const reorderClient = new WSTestClient(`ws://localhost:${PORT + 1}`); reorderWss.on('connection', (ws) => { const buffer: string[] = []; ws.on('message', (data) => { buffer.push(data.toString()); // Every 3 messages, send them in reverse order if (buffer.length === 3) { buffer.reverse().forEach((msg) => ws.send(msg)); buffer.length = 0; } }); }); await reorderClient.connect(); const receivedPromise = new Promise<void>((resolve) => { let count = 0; reorderClient.on('message', () => { count++; if (count === 9) resolve(); }); }); for (let i = 0; i < 9; i++) { reorderClient.send(JSON.stringify({ seq: i })); } await receivedPromise; const messages = reorderClient.getMessages(); const sequences = messages.map((m) => JSON.parse(m.data as string).seq); // Detect ordering violations let outOfOrderCount = 0; for (let i = 1; i < sequences.length; i++) { if (sequences[i] < sequences[i - 1]) { outOfOrderCount++; } } expect(outOfOrderCount).toBeGreaterThan(0); // Confirms reordering occurred reorderClient.close(); reorderWss.close(); }); test('should handle duplicate message detection', async () => { client = new WSTestClient(`ws://localhost:${PORT}`); await client.connect(); // Server that sends duplicates const dupWss = new WebSocketServer({ port: PORT + 2 }); const dupClient = new WSTestClient(`ws://localhost:${PORT + 2}`); dupWss.on('connection', (ws) => { ws.on('message', (data) => { // Send each message twice to simulate duplicates ws.send(data); ws.send(data); }); }); await dupClient.connect(); const receivedPromise = new Promise<void>((resolve) => { let count = 0; dupClient.on('message', () => { count++; if (count === 6) resolve(); // 3 messages x 2 duplicates }); }); for (let i = 0; i < 3; i++) { dupClient.send(JSON.stringify({ id: `msg-${i}`, seq: i })); } await receivedPromise; const messages = dupClient.getMessages(); const uniqueIds = new Set(messages.map((m) => JSON.parse(m.data as string).id)); // Should detect duplicates expect(messages.length).toBe(6); // All messages received expect(uniqueIds.size).toBe(3); // But only 3 unique dupClient.close(); dupWss.close(); }); });
Heartbeat and Ping-Pong Testing
// tests/websocket/heartbeat/ping-pong.spec.ts import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WSTestClient } from '../../helpers/ws-test-client'; import { WebSocketServer } from 'ws'; describe('Heartbeat / Ping-Pong', () => { let wss: WebSocketServer; let client: WSTestClient; const PORT = 8768; beforeAll(() => { wss = new WebSocketServer({ port: PORT }); wss.on('connection', (ws) => { // Server responds to pings automatically (ws library default behavior) ws.on('message', (data) => ws.send(data)); }); }); afterAll(() => wss.close()); afterEach(() => client?.close()); test('client should send periodic heartbeat pings', async () => { client = new WSTestClient(`ws://localhost:${PORT}`); await client.connect(); let pongCount = 0; client.on('pong', () => pongCount++); // Start heartbeat with 200ms interval for fast testing client.startHeartbeat(200, 100); // Wait for several heartbeat cycles await new Promise((resolve) => setTimeout(resolve, 1000)); // Should have received multiple pong responses expect(pongCount).toBeGreaterThanOrEqual(3); }); test('should detect heartbeat timeout when server stops responding', async () => { // Create a server that does not respond to pings const silentWss = new WebSocketServer({ port: PORT + 1 }); silentWss.on('connection', (ws) => { // Override the automatic pong response ws.on('ping', () => { // Deliberately do not send pong }); }); client = new WSTestClient(`ws://localhost:${PORT + 1}`, { maxReconnectAttempts: 0, }); await client.connect(); const timeoutPromise = new Promise<void>((resolve) => { client.on('heartbeat-timeout', () => resolve()); }); client.startHeartbeat(200, 100); // Should detect timeout await timeoutPromise; // Connection should be terminated await new Promise((resolve) => setTimeout(resolve, 200)); expect(client.getState()).not.toBe(1); // Not OPEN silentWss.close(); }); test('heartbeat should reset timeout on each successful pong', async () => { client = new WSTestClient(`ws://localhost:${PORT}`); await client.connect(); let timeoutOccurred = false; client.on('heartbeat-timeout', () => { timeoutOccurred = true; }); // Start heartbeat client.startHeartbeat(100, 500); // Let it run for multiple cycles await new Promise((resolve) => setTimeout(resolve, 2000)); // No timeout should occur because server responds to pings expect(timeoutOccurred).toBe(false); expect(client.getState()).toBe(1); // Still OPEN }); });
Connection Drop Simulation
Simulating Network Disruptions in E2E Tests
// tests/websocket/resilience/network-disruption.spec.ts import { test, expect, Page } from '@playwright/test'; test.describe('WebSocket Network Disruption', () => { test('should reconnect after network interruption', async ({ page, context }) => { await page.goto('/chat'); await page.waitForLoadState('networkidle'); // Verify WebSocket is connected await expect(page.locator('[data-testid="connection-status"]')).toHaveText('Connected'); // Send a message to confirm connectivity await page.fill('[data-testid="message-input"]', 'Hello before disconnect'); await page.click('[data-testid="send-button"]'); await expect(page.locator('[data-testid="messages"] >> text=Hello before disconnect')).toBeVisible(); // Simulate network going offline await context.setOffline(true); // Wait for the connection to be detected as lost await expect(page.locator('[data-testid="connection-status"]')).toHaveText( /disconnected|reconnecting/i, { timeout: 10000 } ); // Restore network await context.setOffline(false); // Should auto-reconnect await expect(page.locator('[data-testid="connection-status"]')).toHaveText( 'Connected', { timeout: 15000 } ); // Verify messaging works after reconnection await page.fill('[data-testid="message-input"]', 'Hello after reconnect'); await page.click('[data-testid="send-button"]'); await expect( page.locator('[data-testid="messages"] >> text=Hello after reconnect') ).toBeVisible(); }); test('should queue messages sent during disconnection', async ({ page, context }) => { await page.goto('/chat'); await page.waitForLoadState('networkidle'); await expect(page.locator('[data-testid="connection-status"]')).toHaveText('Connected'); // Go offline await context.setOffline(true); await expect(page.locator('[data-testid="connection-status"]')).toHaveText( /disconnected|reconnecting/i, { timeout: 10000 } ); // Try to send messages while disconnected await page.fill('[data-testid="message-input"]', 'Queued message 1'); await page.click('[data-testid="send-button"]'); await page.fill('[data-testid="message-input"]', 'Queued message 2'); await page.click('[data-testid="send-button"]'); // Messages should show as pending await expect(page.locator('[data-testid="pending-indicator"]').first()).toBeVisible(); // Restore network await context.setOffline(false); await expect(page.locator('[data-testid="connection-status"]')).toHaveText( 'Connected', { timeout: 15000 } ); // Queued messages should be delivered await expect(page.locator('[data-testid="pending-indicator"]')).toHaveCount(0, { timeout: 10000, }); await expect(page.locator('text=Queued message 1')).toBeVisible(); await expect(page.locator('text=Queued message 2')).toBeVisible(); }); test('should handle slow network gracefully', async ({ page }) => { // Throttle the network to simulate a poor connection const cdpSession = await page.context().newCDPSession(page); await cdpSession.send('Network.emulateNetworkConditions', { offline: false, downloadThroughput: 5000, // 5 KB/s uploadThroughput: 5000, // 5 KB/s latency: 2000, // 2 seconds }); await page.goto('/chat'); // Connection should eventually establish even under slow conditions await expect(page.locator('[data-testid="connection-status"]')).toHaveText( 'Connected', { timeout: 30000 } ); // Messages should still work (possibly with delay) await page.fill('[data-testid="message-input"]', 'Slow network message'); await page.click('[data-testid="send-button"]'); await expect(page.locator('text=Slow network message')).toBeVisible({ timeout: 15000, }); // Reset network conditions await cdpSession.send('Network.emulateNetworkConditions', { offline: false, downloadThroughput: -1, uploadThroughput: -1, latency: 0, }); }); });
Authentication Token Refresh During WebSocket Sessions
// tests/websocket/connection/authentication.spec.ts import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WSTestClient } from '../../helpers/ws-test-client'; import { WebSocketServer, WebSocket } from 'ws'; import { IncomingMessage } from 'http'; describe('WebSocket Authentication', () => { let wss: WebSocketServer; let client: WSTestClient; const PORT = 8769; beforeAll(() => { wss = new WebSocketServer({ port: PORT, verifyClient: (info: { req: IncomingMessage }) => { const token = info.req.headers['authorization']; // Accept connections with valid tokens return token === 'Bearer valid-token' || token === 'Bearer refreshed-token'; }, }); wss.on('connection', (ws) => { ws.send(JSON.stringify({ type: 'auth_success' })); ws.on('message', (data) => ws.send(data)); }); }); afterAll(() => wss.close()); afterEach(() => client?.close()); test('should connect with valid authentication token', async () => { client = new WSTestClient(`ws://localhost:${PORT}`, { headers: { Authorization: 'Bearer valid-token' }, }); await client.connect(); expect(client.getState()).toBe(WebSocket.OPEN); // Wait for auth success message await new Promise<void>((resolve) => { client.on('message', (msg) => { const parsed = JSON.parse(msg.data as string); if (parsed.type === 'auth_success') resolve(); }); }); }); test('should reject connection with invalid token', async () => { client = new WSTestClient(`ws://localhost:${PORT}`, { headers: { Authorization: 'Bearer invalid-token' }, maxReconnectAttempts: 0, }); await expect(client.connect()).rejects.toThrow(); }); test('should reconnect with refreshed token after auth expiry', async () => { let connectionCount = 0; const authWss = new WebSocketServer({ port: PORT + 1, verifyClient: (info: { req: IncomingMessage }) => { connectionCount++; if (connectionCount === 1) return true; // First connection succeeds if (connectionCount === 2) return false; // Simulate token expiry return true; // Third attempt with refreshed token succeeds }, }); authWss.on('connection', (ws) => { ws.on('message', (data) => ws.send(data)); }); client = new WSTestClient(`ws://localhost:${PORT + 1}`, { maxReconnectAttempts: 3, reconnectDelay: 100, }); await client.connect(); expect(client.getState()).toBe(WebSocket.OPEN); // Simulate server-side token expiry by closing connection authWss.clients.forEach((ws) => ws.close(4001, 'Token expired')); // Wait for reconnection attempts await new Promise((resolve) => setTimeout(resolve, 2000)); // Client should have attempted to reconnect expect(connectionCount).toBeGreaterThanOrEqual(2); authWss.close(); }); });
Backpressure Testing
// tests/websocket/resilience/backpressure.spec.ts import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WSTestClient } from '../../helpers/ws-test-client'; import { WebSocketServer, WebSocket } from 'ws'; describe('WebSocket Backpressure', () => { let wss: WebSocketServer; let client: WSTestClient; const PORT = 8770; beforeAll(() => { wss = new WebSocketServer({ port: PORT }); wss.on('connection', (ws) => { ws.on('message', (data) => { // Simulate slow processing setTimeout(() => ws.send(data), 10); }); }); }); afterAll(() => wss.close()); afterEach(() => client?.close()); test('should handle burst of messages without dropping', async () => { client = new WSTestClient(`ws://localhost:${PORT}`); await client.connect(); const messageCount = 500; let received = 0; const allReceived = new Promise<void>((resolve) => { client.on('message', () => { received++; if (received === messageCount) resolve(); }); }); // Send burst of messages for (let i = 0; i < messageCount; i++) { client.send(JSON.stringify({ seq: i, data: 'x'.repeat(100) })); } await allReceived; expect(received).toBe(messageCount); // Verify no messages were dropped const messages = client.getMessages(); const sequences = messages.map((m) => JSON.parse(m.data as string).seq); const uniqueSequences = new Set(sequences); expect(uniqueSequences.size).toBe(messageCount); }); test('should handle large payloads', async () => { client = new WSTestClient(`ws://localhost:${PORT}`); await client.connect(); // Send increasingly large messages const sizes = [1024, 10240, 102400, 1048576]; // 1KB, 10KB, 100KB, 1MB for (const size of sizes) { const payload = JSON.stringify({ size, data: 'x'.repeat(size), }); const responsePromise = new Promise<void>((resolve) => { client.once('message', () => resolve()); }); client.send(payload); await responsePromise; const lastMessage = client.getMessages().at(-1); expect(lastMessage).toBeDefined(); const parsed = JSON.parse(lastMessage!.data as string); expect(parsed.data.length).toBe(size); } }); });
Concurrent Connection Limits
// tests/websocket/connection/concurrent-connections.spec.ts import { describe, test, expect, beforeAll, afterAll } from 'vitest'; import { WSTestClient } from '../../helpers/ws-test-client'; import { WebSocketServer } from 'ws'; describe('Concurrent WebSocket Connections', () => { let wss: WebSocketServer; const PORT = 8771; const MAX_CONNECTIONS = 10; beforeAll(() => { wss = new WebSocketServer({ port: PORT }); let connectionCount = 0; wss.on('connection', (ws) => { connectionCount++; if (connectionCount > MAX_CONNECTIONS) { ws.close(4029, 'Too many connections'); return; } ws.on('message', (data) => ws.send(data)); ws.on('close', () => connectionCount--); }); }); afterAll(() => wss.close()); test('should handle multiple concurrent connections', async () => { const clients: WSTestClient[] = []; const count = 5; for (let i = 0; i < count; i++) { const client = new WSTestClient(`ws://localhost:${PORT}`, { maxReconnectAttempts: 0, }); await client.connect(); clients.push(client); } // All clients should be connected for (const client of clients) { expect(client.getStateName()).toBe('OPEN'); } // All clients should be able to send and receive for (let i = 0; i < clients.length; i++) { const responsePromise = new Promise<void>((resolve) => { clients[i].once('message', () => resolve()); }); clients[i].send(JSON.stringify({ client: i })); await responsePromise; } // Clean up clients.forEach((c) => c.close()); }); test('should reject connections beyond the limit', async () => { const clients: WSTestClient[] = []; // Fill up to the limit for (let i = 0; i < MAX_CONNECTIONS; i++) { const client = new WSTestClient(`ws://localhost:${PORT}`, { maxReconnectAttempts: 0, }); await client.connect(); clients.push(client); } // The next connection should be rejected const extraClient = new WSTestClient(`ws://localhost:${PORT}`, { maxReconnectAttempts: 0, }); await extraClient.connect(); // Should receive a close frame with the rejection code const closePromise = new Promise<number>((resolve) => { extraClient.on('close', (code) => resolve(code)); }); const code = await closePromise; expect(code).toBe(4029); // Clean up clients.forEach((c) => c.close()); extraClient.close(); }); });
Configuration
Playwright Configuration for WebSocket E2E Tests
// playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests/websocket/e2e', fullyParallel: false, // WebSocket tests may share server state retries: 2, timeout: 30000, reporter: [ ['html', { open: 'never' }], ['json', { outputFile: 'ws-test-results.json' }], ], use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'ws-chrome', use: { ...devices['Desktop Chrome'] }, }, { name: 'ws-firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'ws-mobile', use: { ...devices['iPhone 14'] }, }, ], });
WebSocket Test Configuration
// config/ws-test-config.ts export const WS_TEST_CONFIG = { // Server configuration serverUrl: process.env.WS_SERVER_URL || 'ws://localhost:8080', serverPort: parseInt(process.env.WS_TEST_PORT || '8080', 10), // Connection settings connectTimeout: 5000, maxReconnectAttempts: 5, reconnectBaseDelay: 1000, reconnectMaxDelay: 30000, // Heartbeat settings heartbeatInterval: 30000, heartbeatTimeout: 5000, // Message settings maxMessageSize: 1048576, // 1MB messageTimeout: 10000, // Test data burstMessageCount: 500, concurrentClientCount: 10, largePayloadSizes: [1024, 10240, 102400, 1048576], };
Best Practices
-
Always implement exponential backoff with jitter for reconnection -- A fixed reconnect interval causes all disconnected clients to reconnect simultaneously, creating a "thundering herd" that can overwhelm the server. Add random jitter to spread reconnection attempts.
-
Use connection state machines -- Model the WebSocket lifecycle as a state machine with explicit states (DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING) and valid transitions. This prevents impossible state combinations like sending messages while disconnected.
-
Implement message acknowledgment for critical operations -- For messages that must not be lost (e.g., financial transactions, chat messages), implement application-level acknowledgments. Do not rely solely on TCP delivery guarantees.
-
Test with real-world disconnect scenarios -- Lab-perfect connections hide bugs. Test with WiFi-to-cellular transitions, VPN disconnects, laptop lid close/open, and browser tab backgrounding.
-
Add sequence numbers to messages -- Every message should include a monotonically increasing sequence number. This enables detection of gaps, duplicates, and reordering on the receiving end.
-
Handle the "half-open" connection state -- A TCP connection can appear open on one side while closed on the other. Heartbeats detect this condition. Without heartbeats, a client may believe it is connected while the server has already dropped the connection.
-
Buffer messages during reconnection -- Messages sent while the WebSocket is reconnecting should be queued and delivered once the connection is re-established, not silently dropped.
-
Test binary message handling separately from text -- Binary frames (ArrayBuffer, Blob) and text frames have different serialization paths. Test both frame types to ensure the application handles each correctly.
-
Implement connection pooling for multiple channels -- Applications that need multiple logical channels should multiplex over a single WebSocket connection rather than opening separate connections per channel.
-
Set appropriate close codes -- Use RFC 6455 close codes correctly: 1000 (normal), 1001 (going away), 1008 (policy violation), 1011 (unexpected condition). Custom codes should be in the 4000-4999 range.
-
Test WebSocket behavior across browser tabs -- Browsers may throttle or suspend WebSocket connections in background tabs. Test that reconnection works correctly when a user returns to a backgrounded tab.
-
Monitor WebSocket connection metrics in production -- Track connection duration, reconnection frequency, message latency, and error rates. These metrics reveal reliability issues that tests alone cannot catch.
Anti-Patterns to Avoid
-
Reconnecting immediately without backoff -- Instant reconnection creates a retry storm that wastes bandwidth and can trigger rate limiting or server overload. Always use exponential backoff.
-
Silently dropping messages during disconnection -- When a user sends a chat message during a brief disconnect and the message disappears, it erodes trust. Queue messages and deliver them after reconnection.
-
Using WebSocket as the only data channel -- WebSocket connections are not guaranteed to stay open. Critical operations should have an HTTP fallback. Do not build flows that are impossible to complete without a persistent WebSocket.
-
Ignoring close frames and codes -- Different close codes have different meanings. Code 1001 (going away) suggests the server is restarting and reconnection will likely succeed. Code 1008 (policy violation) suggests the client should not reconnect.
-
Opening a new WebSocket per request -- WebSocket's advantage is persistent connections. Opening and closing a WebSocket for each message negates the protocol's benefits and adds significant overhead.
-
Trusting message order across reconnections -- A new WebSocket connection is a new TCP stream. Messages in transit during reconnection may be lost or arrive on the new connection out of order. Always use sequence numbers.
-
Not testing concurrent connection limits -- Browsers enforce per-domain WebSocket limits. Applications that open too many connections will silently fail to connect, causing features to break with no visible error.
Debugging Tips
-
Use Chrome DevTools WebSocket inspector -- The Network tab in Chrome DevTools shows individual WebSocket frames (both sent and received). Filter by "WS" to see only WebSocket traffic. Click on a connection to inspect individual frames.
-
Log connection state transitions -- Add logging for every state change: CONNECTING, OPEN, CLOSING, CLOSED, and any custom states like RECONNECTING. This trace is invaluable for debugging intermittent connection issues.
-
Check for load balancer idle timeout -- Many load balancers (AWS ALB, nginx) close idle WebSocket connections after 60 seconds. If connections drop without heartbeat activity, the load balancer timeout is the likely culprit.
-
Verify WebSocket upgrade headers -- Use
to verify the server responds with a 101 Switching Protocols response. A 200 OK indicates the upgrade failed.curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -
Test with
for quick manual verification -- Thewscat
command-line tool (wscat
) provides a simple way to interactively test WebSocket endpoints without building a test client.npx wscat -c ws://localhost:8080 -
Monitor
before sending -- Always checkreadyState
before callingws.readyState === WebSocket.OPEN
. Sending on a non-OPEN socket throws an error that may not be caught by error boundaries.ws.send() -
Check for CORS issues on WebSocket upgrade -- While the WebSocket protocol itself does not enforce CORS, some reverse proxies and CDNs may block WebSocket upgrade requests based on Origin headers. Check server logs for rejected upgrade requests.
-
Use Wireshark to inspect WebSocket frames at the protocol level -- When high-level debugging is insufficient, Wireshark can decode WebSocket frames and show the raw binary content, opcode, masking, and frame boundaries.
-
Verify that ping/pong frames are not confused with application messages -- WebSocket control frames (ping, pong, close) are distinct from data frames. Ensure your message handler does not process control frames as application messages.
-
Test reconnection with server-side logging -- Log connection and disconnection events on the server with client identifiers. Compare server-side logs with client-side reconnection logs to identify mismatches where the client believes it is connected but the server does not have a matching connection.