# WebSockets and Real-Time Testing

> **When to use**: When your application uses WebSockets, Server-Sent Events (SSE), or polling for real-time features -- chat, live dashboards, notifications, collaborative editing, stock tickers, live sports scores.
> **Prerequisites**: [core/assertions-and-waiting.md](assertions-and-waiting.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md)

## Quick Reference

```typescript
// Listen for WebSocket connections
page.on("websocket", (ws) => {
  console.log("WebSocket opened:", ws.url())

  ws.on("framesent", (frame) => console.log("Sent:", frame.payload))
  ws.on("framereceived", (frame) => console.log("Received:", frame.payload))
  ws.on("close", () => console.log("WebSocket closed"))
})

// Mock a WebSocket via route (Playwright 1.48+)
await page.routeWebSocket("**/ws", (ws) => {
  ws.onMessage((message) => {
    ws.send(JSON.stringify({ echo: message }))
  })
})
```

## Patterns

### Observing WebSocket Traffic

**Use when**: You need to verify that your app sends and receives the correct WebSocket messages without modifying them.
**Avoid when**: You need to intercept or mock the messages. Use `routeWebSocket` instead.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("chat message is sent over WebSocket", async ({ page }) => {
  const messages: { direction: string; payload: string }[] = []

  page.on("websocket", (ws) => {
    ws.on("framesent", (frame) => {
      messages.push({ direction: "sent", payload: String(frame.payload) })
    })
    ws.on("framereceived", (frame) => {
      messages.push({ direction: "received", payload: String(frame.payload) })
    })
  })

  await page.goto("/chat")
  await page.getByRole("textbox", { name: "Message" }).fill("Hello!")
  await page.getByRole("button", { name: "Send" }).click()

  // Wait for the message to appear in UI (confirms round-trip)
  await expect(page.getByText("Hello!")).toBeVisible()

  // Verify WebSocket traffic
  const sentMessage = messages.find((m) => m.direction === "sent" && m.payload.includes("Hello!"))
  expect(sentMessage).toBeDefined()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("chat message is sent over WebSocket", async ({ page }) => {
  const messages = []

  page.on("websocket", (ws) => {
    ws.on("framesent", (frame) => {
      messages.push({ direction: "sent", payload: String(frame.payload) })
    })
    ws.on("framereceived", (frame) => {
      messages.push({ direction: "received", payload: String(frame.payload) })
    })
  })

  await page.goto("/chat")
  await page.getByRole("textbox", { name: "Message" }).fill("Hello!")
  await page.getByRole("button", { name: "Send" }).click()

  await expect(page.getByText("Hello!")).toBeVisible()

  const sentMessage = messages.find((m) => m.direction === "sent" && m.payload.includes("Hello!"))
  expect(sentMessage).toBeDefined()
})
```

### Waiting for a Specific WebSocket Message

**Use when**: Your test depends on a particular server-pushed message before proceeding.
**Avoid when**: The UI already reflects the message. Assert on the UI instead.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("wait for server acknowledgment over WebSocket", async ({ page }) => {
  // Create a promise that resolves when we get the specific message
  const ackPromise = new Promise<void>((resolve) => {
    page.on("websocket", (ws) => {
      ws.on("framereceived", (frame) => {
        const data = JSON.parse(String(frame.payload))
        if (data.type === "message_ack") {
          resolve()
        }
      })
    })
  })

  await page.goto("/chat")
  await page.getByRole("textbox", { name: "Message" }).fill("Important update")
  await page.getByRole("button", { name: "Send" }).click()

  // Wait for server to acknowledge
  await ackPromise

  // Now verify the message shows a "delivered" checkmark
  await expect(page.getByTestId("message-status").last()).toHaveText("Delivered")
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("wait for server acknowledgment over WebSocket", async ({ page }) => {
  const ackPromise = new Promise((resolve) => {
    page.on("websocket", (ws) => {
      ws.on("framereceived", (frame) => {
        const data = JSON.parse(String(frame.payload))
        if (data.type === "message_ack") {
          resolve()
        }
      })
    })
  })

  await page.goto("/chat")
  await page.getByRole("textbox", { name: "Message" }).fill("Important update")
  await page.getByRole("button", { name: "Send" }).click()

  await ackPromise
  await expect(page.getByTestId("message-status").last()).toHaveText("Delivered")
})
```

### Mocking WebSocket Messages with `routeWebSocket`

**Use when**: You want to control what the server sends to test specific UI states -- error messages, edge cases, high-volume data -- without a real backend.
**Avoid when**: You need to test actual server behavior. Use a real backend.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("display notification when server pushes an alert", async ({ page }) => {
  const wsRoute = await page.routeWebSocket("**/ws/notifications", (ws) => {
    // Let the app send its initial handshake
    ws.onMessage((message) => {
      const data = JSON.parse(message)
      if (data.type === "subscribe") {
        ws.send(JSON.stringify({ type: "subscribed", channel: data.channel }))
      }
    })

    // Push a notification after a short delay
    setTimeout(() => {
      ws.send(
        JSON.stringify({
          type: "notification",
          title: "Server Alert",
          body: "Deployment completed successfully",
          severity: "info",
        })
      )
    }, 500)
  })

  await page.goto("/dashboard")

  // Verify the notification appears in the UI
  await expect(page.getByRole("alert")).toContainText("Deployment completed successfully")
})

test("handle WebSocket server error gracefully", async ({ page }) => {
  await page.routeWebSocket("**/ws", (ws) => {
    // Immediately close with an error code
    ws.close({ code: 1011, reason: "Internal server error" })
  })

  await page.goto("/chat")

  // App should show a reconnection message, not crash
  await expect(page.getByText("Connection lost. Reconnecting...")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("display notification when server pushes an alert", async ({ page }) => {
  await page.routeWebSocket("**/ws/notifications", (ws) => {
    ws.onMessage((message) => {
      const data = JSON.parse(message)
      if (data.type === "subscribe") {
        ws.send(JSON.stringify({ type: "subscribed", channel: data.channel }))
      }
    })

    setTimeout(() => {
      ws.send(
        JSON.stringify({
          type: "notification",
          title: "Server Alert",
          body: "Deployment completed successfully",
          severity: "info",
        })
      )
    }, 500)
  })

  await page.goto("/dashboard")
  await expect(page.getByRole("alert")).toContainText("Deployment completed successfully")
})

test("handle WebSocket server error gracefully", async ({ page }) => {
  await page.routeWebSocket("**/ws", (ws) => {
    ws.close({ code: 1011, reason: "Internal server error" })
  })

  await page.goto("/chat")
  await expect(page.getByText("Connection lost. Reconnecting...")).toBeVisible()
})
```

### Forwarding with Modification (Man-in-the-Middle)

**Use when**: You want to connect to the real server but intercept, modify, or inject messages.
**Avoid when**: Full mocking (`routeWebSocket` without `connectToServer`) is sufficient.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("inject a fake high-priority message into real stream", async ({ page }) => {
  await page.routeWebSocket("**/ws/feed", (ws) => {
    const server = ws.connectToServer()

    // Forward all messages from server to client, but inject extras
    server.onMessage((message) => {
      ws.send(message) // Forward the real message
    })

    // Forward all client messages to server
    ws.onMessage((message) => {
      server.send(message)
    })

    // Inject a synthetic message after 1 second
    setTimeout(() => {
      ws.send(
        JSON.stringify({
          type: "alert",
          priority: "high",
          text: "Injected test alert",
        })
      )
    }, 1000)
  })

  await page.goto("/live-feed")
  await expect(page.getByText("Injected test alert")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("inject a fake high-priority message into real stream", async ({ page }) => {
  await page.routeWebSocket("**/ws/feed", (ws) => {
    const server = ws.connectToServer()

    server.onMessage((message) => {
      ws.send(message)
    })

    ws.onMessage((message) => {
      server.send(message)
    })

    setTimeout(() => {
      ws.send(
        JSON.stringify({
          type: "alert",
          priority: "high",
          text: "Injected test alert",
        })
      )
    }, 1000)
  })

  await page.goto("/live-feed")
  await expect(page.getByText("Injected test alert")).toBeVisible()
})
```

### Server-Sent Events (SSE) Testing

**Use when**: Your app uses `EventSource` for server-to-client streaming (live logs, progress updates, news feeds).
**Avoid when**: The app uses WebSockets. SSE is HTTP-based and intercepted differently.

SSE responses are standard HTTP -- intercept them with `page.route()` and return a streaming response.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("SSE live log stream displays entries", async ({ page }) => {
  // Intercept the SSE endpoint and return controlled events
  await page.route("**/api/logs/stream", async (route) => {
    const events = [
      'data: {"level":"info","message":"Server started"}\n\n',
      'data: {"level":"warn","message":"High memory usage"}\n\n',
      'data: {"level":"error","message":"Connection timeout"}\n\n',
    ]

    await route.fulfill({
      status: 200,
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
      body: events.join(""),
    })
  })

  await page.goto("/admin/logs")

  await expect(page.getByText("Server started")).toBeVisible()
  await expect(page.getByText("High memory usage")).toBeVisible()
  await expect(page.getByText("Connection timeout")).toBeVisible()
})

test("SSE reconnection on connection drop", async ({ page }) => {
  let requestCount = 0

  await page.route("**/api/events", async (route) => {
    requestCount++
    if (requestCount === 1) {
      // First request: send one event then close abruptly
      await route.fulfill({
        status: 200,
        headers: { "Content-Type": "text/event-stream" },
        body: 'data: {"msg":"first"}\n\n',
      })
    } else {
      // Reconnection: send the next event
      await route.fulfill({
        status: 200,
        headers: { "Content-Type": "text/event-stream" },
        body: 'data: {"msg":"reconnected"}\n\n',
      })
    }
  })

  await page.goto("/live")
  await expect(page.getByText("first")).toBeVisible()
  // EventSource auto-reconnects; verify the app handles it
  await expect(page.getByText("reconnected")).toBeVisible({ timeout: 10000 })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("SSE live log stream displays entries", async ({ page }) => {
  await page.route("**/api/logs/stream", async (route) => {
    const events = [
      'data: {"level":"info","message":"Server started"}\n\n',
      'data: {"level":"warn","message":"High memory usage"}\n\n',
      'data: {"level":"error","message":"Connection timeout"}\n\n',
    ]

    await route.fulfill({
      status: 200,
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
      body: events.join(""),
    })
  })

  await page.goto("/admin/logs")

  await expect(page.getByText("Server started")).toBeVisible()
  await expect(page.getByText("High memory usage")).toBeVisible()
  await expect(page.getByText("Connection timeout")).toBeVisible()
})
```

### Polling-Based Real-Time Testing

**Use when**: Your app uses HTTP polling (setInterval + fetch) instead of WebSockets or SSE.
**Avoid when**: The app uses WebSockets or SSE -- use the patterns above.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("polling updates dashboard data every interval", async ({ page }) => {
  let callCount = 0

  await page.route("**/api/dashboard/stats", async (route) => {
    callCount++
    const data =
      callCount === 1 ? { activeUsers: 100, revenue: 5000 } : { activeUsers: 142, revenue: 5250 }

    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify(data),
    })
  })

  await page.goto("/dashboard")

  // First poll result
  await expect(page.getByTestId("active-users")).toHaveText("100")

  // Wait for the second poll to update the UI
  await expect(page.getByTestId("active-users")).toHaveText("142", { timeout: 15000 })

  // Verify at least 2 requests were made
  expect(callCount).toBeGreaterThanOrEqual(2)
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("polling updates dashboard data every interval", async ({ page }) => {
  let callCount = 0

  await page.route("**/api/dashboard/stats", async (route) => {
    callCount++
    const data =
      callCount === 1 ? { activeUsers: 100, revenue: 5000 } : { activeUsers: 142, revenue: 5250 }

    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify(data),
    })
  })

  await page.goto("/dashboard")
  await expect(page.getByTestId("active-users")).toHaveText("100")
  await expect(page.getByTestId("active-users")).toHaveText("142", { timeout: 15000 })
  expect(callCount).toBeGreaterThanOrEqual(2)
})
```

### WebSocket Connection Lifecycle

**Use when**: You need to verify that your app handles connection, disconnection, and reconnection properly.
**Avoid when**: Connection lifecycle is not user-visible.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("app reconnects after WebSocket drops", async ({ page }) => {
  let connectionCount = 0

  await page.routeWebSocket("**/ws", (ws) => {
    connectionCount++

    if (connectionCount === 1) {
      // First connection: close after a brief moment
      setTimeout(() => ws.close({ code: 1006, reason: "Abnormal closure" }), 500)
    } else {
      // Second connection (reconnect): stay open and respond
      ws.onMessage((message) => {
        ws.send(JSON.stringify({ type: "pong" }))
      })
    }
  })

  await page.goto("/app")

  // App detects disconnect and shows status
  await expect(page.getByText("Reconnecting...")).toBeVisible()

  // App reconnects and status returns to normal
  await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 })

  expect(connectionCount).toBe(2)
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("app reconnects after WebSocket drops", async ({ page }) => {
  let connectionCount = 0

  await page.routeWebSocket("**/ws", (ws) => {
    connectionCount++

    if (connectionCount === 1) {
      setTimeout(() => ws.close({ code: 1006, reason: "Abnormal closure" }), 500)
    } else {
      ws.onMessage((message) => {
        ws.send(JSON.stringify({ type: "pong" }))
      })
    }
  })

  await page.goto("/app")
  await expect(page.getByText("Reconnecting...")).toBeVisible()
  await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 })
  expect(connectionCount).toBe(2)
})
```

## Decision Guide

| Scenario                                  | Approach                                                  | Why                                                        |
| ----------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- |
| Verify app sends correct WS message       | `page.on('websocket')` + `ws.on('framesent')`             | Observe without intercepting                               |
| Verify app handles server push            | `page.routeWebSocket()` with mock server                  | Full control over what the "server" sends                  |
| Test with real server but inject messages | `routeWebSocket` + `connectToServer()`                    | Man-in-the-middle: forward real traffic plus inject extras |
| Test SSE endpoint                         | `page.route()` with `text/event-stream` content type      | SSE is HTTP -- standard route interception works           |
| Test HTTP polling                         | `page.route()` with changing responses per call           | Increment a counter; return different data each call       |
| Verify reconnection logic                 | `routeWebSocket` that closes the first connection         | Simulate server failure, verify the app retries            |
| Test binary WebSocket data                | `ws.on('framereceived')`, check `frame.payload` as Buffer | Binary frames arrive as `Buffer` in Node.js                |

## Anti-Patterns

| Don't Do This                                            | Problem                                                          | Do This Instead                                                          |
| -------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `page.waitForTimeout(3000)` to wait for WS message       | Arbitrary delay; flaky and slow                                  | `await expect(page.getByText('msg')).toBeVisible()` or wait on a Promise |
| Directly construct `WebSocket` in `page.evaluate`        | You lose Playwright's observation and routing capabilities       | Let the app create its own WebSocket; intercept via `routeWebSocket`     |
| Ignore WebSocket close codes in mocks                    | App may behave differently for 1000 (normal) vs 1006 (abnormal)  | Use the correct close code: `ws.close({ code: 1000 })`                   |
| Test real-time features against live third-party servers | Flaky, slow, and may incur costs                                 | Mock the WebSocket or SSE endpoint                                       |
| Assert on raw WebSocket frame content in every test      | Couples tests to wire protocol; breaks on payload format changes | Assert on the UI -- that is what users see                               |
| Forget to handle binary vs text frames                   | `frame.payload` can be `string` or `Buffer`                      | Check frame type or use `String(frame.payload)` consistently             |

## Troubleshooting

| Symptom                                          | Cause                                                           | Fix                                                                                           |
| ------------------------------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `page.on('websocket')` never fires               | WebSocket connects before the listener is attached              | Register the listener before `page.goto()`                                                    |
| `routeWebSocket` does not intercept              | URL pattern does not match the actual WebSocket URL             | Check the URL in DevTools Network tab; update the glob pattern                                |
| SSE mock returns all events at once              | `route.fulfill` sends the body synchronously                    | For true streaming, use the real server or chunk the response with pauses via `page.evaluate` |
| WebSocket messages arrive but UI does not update | App processes messages asynchronously; assertion runs too early | Use `await expect(...).toBeVisible()` which auto-retries                                      |
| Binary frames show as garbled text               | `String(frame.payload)` on binary data produces garbage         | Treat `frame.payload` as `Buffer` and decode appropriately                                    |
| Reconnection test is flaky                       | App has exponential backoff; timeout too short                  | Increase assertion timeout: `toBeVisible({ timeout: 15000 })`                                 |

## Related

- [core/multi-user-and-collaboration.md](multi-user-and-collaboration.md) -- multi-user tests that rely on WebSocket for real-time sync
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- auto-retrying assertions for async UI updates
- [core/when-to-mock.md](when-to-mock.md) -- deciding when to mock WebSocket vs use real server
- [core/debugging.md](debugging.md) -- tracing WebSocket frames in Playwright traces
