How to fix Playwright websocket or SSE-dependent tests in unstable CI networks?
PlaywrightTests that depend on WebSocket connections or Server-Sent Events (SSE) are inherently more sensitive to CI network instability than regular HTTP tests. Connection establishment latency, reconnection delays, and message delivery jitter all increase under CI network conditions, causing tests that pass locally to fail intermittently in CI. The underlying issue is that tests assert on received event timing rather than on the observable UI state that reflects those events.
Common mistake
test('receives live order update', async ({ page }) => {
await page.goto('https://app.example.com/orders/live');
// Wait a fixed time then assert — the event may take longer in CI
await page.waitForTimeout(2000);
await expect(page.getByText('Order #1042 processing')).toBeVisible();
});
Also fragile: asserting on the WebSocket message content directly rather than the UI state it produces:
const messages = [];
await page.on('websocket', ws => {
ws.on('framereceived', frame => messages.push(frame.payload));
});
// Message arrival timing is non-deterministic
await expect(() => messages.length).toBe(3); // Not a valid Playwright assertion
The fix
Assert on the UI state that websocket/SSE messages produce, with bounded timeouts large enough for CI variability:
import { test, expect } from '@playwright/test';
test('shows live order status updates', async ({ page }) => {
await page.goto('https://app.example.com/orders/live');
// Wait for initial connection indicator
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15000 });
// Trigger an order status change (via API or UI action)
await page.request.post('/api/test/trigger-order-update', {
data: { orderId: 1042, status: 'processing' },
});
// Assert on the UI outcome with a generous timeout for CI delivery latency
await expect(
page.getByRole('row', { name: /Order #1042/ }).getByText('Processing')
).toBeVisible({ timeout: 20000 });
});
For SSE-dependent data loading, mock the SSE endpoint in CI to eliminate network variability:
test('live dashboard updates', async ({ page }) => {
// Mock SSE stream for deterministic CI behavior
await page.route('**/api/events/live', async (route) => {
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: [
'data: {"type":"order_update","orderId":1042,"status":"processing"}\n\n',
'data: {"type":"stats_update","revenue":15000}\n\n',
].join(''),
});
});
await page.goto('https://app.example.com/dashboard');
await expect(page.getByText('$15,000')).toBeVisible({ timeout: 10000 });
});
Why it works
expect(locator).toBeVisible({ timeout: N }) retries the check every ~100ms until the element appears or the timeout expires. This retry behavior absorbs delivery jitter — whether the WebSocket message arrives in 200ms or 5 seconds, the assertion waits for the UI to reflect it. Mocking SSE streams with page.route() eliminates the network variable entirely, replacing unpredictable server push with deterministic test-controlled data. This is the most reliable approach for CI environments where network path latency is variable.
Tips
- Set timeouts for websocket/SSE assertions to 3–5x higher than the typical message delivery time locally — this headroom absorbs CI network variability without making tests unreasonably slow.
- Use page.on('websocket', ...) listener during test development to understand the actual message flow before writing assertions — remove it from final test code.
- If the app shows an explicit connection status indicator (green dot, "Live" badge), assert on its visibility before asserting on event-driven content — it confirms the connection was established.
- For critical real-time features, consider integration tests against a local mock server rather than a live WebSocket backend in CI — it removes the external dependency entirely.