Real-Time Testing Guide

How to Test Server Sent Events with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing Server-Sent Events with Playwright. EventSource API interception, auto-reconnect validation, last-event-id header forwarding, custom event types, connection state management, backpressure handling, and the pitfalls that break real SSE test suites.

95%

According to the HTTP Archive Web Almanac 2024, Server-Sent Events adoption grew steadily as teams moved from polling to native streaming for dashboards, notifications, and AI token delivery, with 95% of modern browsers supporting the EventSource API natively.

HTTP Archive Web Almanac 2024

0sDefault SSE reconnect delay
0SSE scenarios covered
0Event types tested
0%Fewer lines with Assrt

Server-Sent Events End-to-End Flow

BrowserEventSource APIHTTP ServerEvent Streamnew EventSource('/events')GET /events (Accept: text/event-stream)200 OK, Content-Type: text/event-streamdata: {payload}\n\nonmessage callback firesevent: custom\ndata: ...\n\naddEventListener('custom', cb)Connection dropsAuto-reconnect with Last-Event-ID

1. Why Testing Server-Sent Events Is Harder Than It Looks

Server-Sent Events feel deceptively simple. The browser opens a long-lived HTTP connection, the server pushes text frames, and the EventSource API handles parsing, dispatching, and reconnection automatically. From the outside, it looks like a one-liner: new EventSource('/stream'). But that simplicity hides six structural challenges that make SSE testing significantly more complex than testing standard request/response endpoints.

First, the EventSource API is not directly accessible from Playwright's page object. You cannot call page.eventSource() the way you would call page.request(). Instead, you must intercept the underlying HTTP request using page.route() or page.on('response'), or inject JavaScript into the page context via page.evaluate()to spy on the EventSource constructor. Neither approach is documented in Playwright's official examples, which focus on fetch and XHR interception.

Second, SSE connections are long-lived. A typical REST test opens a page, makes a request, asserts the response, and finishes. An SSE test must keep the connection alive, wait for multiple events over time, and verify ordering and completeness without introducing flaky timeouts. Third, the auto-reconnect behavior built into EventSource fires automatically when the connection drops, and it sends a Last-Event-ID header that your server must respect to resume delivery. Testing this requires you to simulate a server crash mid-stream and verify that the client reconnects and receives only the events it missed.

Fourth, custom event types (the event: field in the SSE protocol) route to different listeners on the client. The default onmessage handler only fires for events without an explicit type. Events with event: notification or event: heartbeat require dedicated addEventListener calls. Missing this distinction is one of the most common SSE bugs, and your tests need to verify that each event type reaches its correct handler. Fifth, connection state transitions between CONNECTING (0), OPEN (1), and CLOSED (2) must be observable in your test to confirm that the UI reacts to connection loss and recovery. Sixth, backpressure: when the server sends events faster than the client can process them, the TCP buffer fills, and the server must handle write blocking gracefully. Testing backpressure requires controlled event rates and timing assertions that are inherently race-condition prone.

SSE Protocol Lifecycle

🌐

Client

new EventSource(url)

↪️

HTTP GET

Accept: text/event-stream

⚙️

Server

200 + streaming body

🔔

Events Flow

data: ... frames

Connection Drop

Network error or server restart

↪️

Auto-Reconnect

With Last-Event-ID header

Resume

Missed events replayed

2. Setting Up a Reliable SSE Test Environment

Before writing any SSE tests, you need a controllable server that can send events on demand, simulate disconnections, and track which events each client has received. A production SSE endpoint sends events based on real application logic (database changes, external webhooks, user actions), which makes it non-deterministic and impossible to test reliably. Instead, create a lightweight test server that exposes explicit control endpoints.

test/helpers/sse-test-server.ts

Playwright Configuration for SSE Testing

SSE tests require careful timeout configuration. The default Playwright action timeout of 5 seconds is too aggressive for scenarios that wait for multiple events over a stream. Set a generous timeout for assertions that depend on streamed data, and use the test server's control endpoints in globalSetup and globalTeardown.

playwright.config.ts
Starting the SSE test server

3. Scenario: Basic EventSource Stream Happy Path

The first scenario every SSE integration needs is the basic happy path: your page opens an EventSource connection, the server sends a series of events, and the UI renders each one as it arrives. This verifies that the connection is established correctly, the Content-Type header is set to text/event-stream, events are parsed by the browser's built-in SSE parser, and your frontend code handles the onmessage callback correctly. If this scenario fails, nothing else will work.

1

Basic EventSource Stream Happy Path

Straightforward

Goal

Open a page that creates an EventSource connection, send three events from the test server, and verify that all three events appear in the UI in the correct order.

Preconditions

  • App running at APP_BASE_URL
  • SSE test server running on port 3099
  • App configured to connect to http://localhost:3099/events

Playwright Implementation

basic-stream.spec.ts

What to Assert Beyond the UI

Basic stream verification checklist

  • Content-Type is text/event-stream
  • Cache-Control is no-cache
  • Events arrive in the order they were sent
  • EventSource readyState is OPEN (1) after connection
  • No duplicate events rendered in the UI

Basic SSE Stream: Playwright vs Assrt

import { test, expect } from '@playwright/test';

test('receives SSE events', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByTestId('sse-status'))
    .toHaveText('Connected');

  for (const msg of ['First', 'Second', 'Third']) {
    await fetch('http://localhost:3099/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ data: { message: msg } }),
    });
  }

  const items = page.getByTestId('event-list').locator('li');
  await expect(items).toHaveCount(3);
  await expect(items.nth(0)).toContainText('First');
  await expect(items.nth(2)).toContainText('Third');
});
39% fewer lines

4. Scenario: Custom Event Types and Routing

The SSE protocol supports an event: field that routes events to different listeners. Without this field, all events go to the generic onmessage handler. With it, events dispatch only to listeners registered via addEventListener('eventType', callback). This distinction catches many teams off guard: they register onmessage and wonder why their typed events never arrive. Your tests must verify that each event type reaches its designated handler and that the default handler ignores typed events.

2

Custom Event Types and Routing

Moderate

Goal

Send events with different event: types (notification, heartbeat, update) and verify that each type is routed to its correct UI section. Also verify that typed events do not trigger the default onmessage handler.

Playwright Implementation

custom-events.spec.ts

What to Assert Beyond the UI

  • Typed events do not trigger the generic onmessage handler
  • Each event type dispatches only to its registered addEventListener callback
  • Unknown event types are silently ignored (no errors in the console)
  • The event.type property in the callback matches the server-sent type

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Auto-Reconnect and Last-Event-ID

One of the most valuable features of the EventSource API is its built-in auto-reconnect behavior. When the connection drops (server restart, network blip, load balancer timeout), the browser automatically attempts to re-establish the connection after a configurable delay. Crucially, it sends the Last-Event-ID header with the ID of the last event it successfully received. This allows the server to replay only the events the client missed, preventing data loss during transient failures. Testing this behavior requires precise control over when the server disconnects and what events it sends before and after the reconnection.

3

Auto-Reconnect with Last-Event-ID

Complex

Goal

Send events with IDs, simulate a server disconnection, verify the client reconnects with the correct Last-Event-ID header, and confirm that only the missed events are replayed.

Preconditions

  • SSE test server running with event ID tracking
  • Retry interval set to 1000ms (via /set-retry?ms=1000)
  • Server supports Last-Event-ID header for replay

Playwright Implementation

reconnect.spec.ts

What to Assert Beyond the UI

Reconnect verification checklist

  • Last-Event-ID header matches the last received event ID
  • Reconnection happens automatically without user action
  • Server replays only missed events after reconnect
  • No duplicate events after reconnection
  • Custom retry interval from server is respected
  • UI shows reconnecting state during the gap

Auto-Reconnect: Playwright vs Assrt

test('reconnects with Last-Event-ID', async ({ page }) => {
  const reconnects: { lastEventId: string | null }[] = [];
  page.on('request', (req) => {
    if (req.url().includes('/events') &&
        req.headers()['last-event-id']) {
      reconnects.push({
        lastEventId: req.headers()['last-event-id'],
      });
    }
  });

  await page.goto('/dashboard');
  await expect(page.getByTestId('sse-status'))
    .toHaveText('Connected');

  // Send 3 events, disconnect, verify reconnect
  for (let i = 1; i <= 3; i++) {
    await fetch('http://localhost:3099/send', {
      method: 'POST',
      body: JSON.stringify({ id: String(i), data: { message: i } }),
    });
  }
  await fetch('http://localhost:3099/disconnect');
  await expect(page.getByTestId('sse-status'))
    .toHaveText('Connected', { timeout: 5000 });
  expect(reconnects[0].lastEventId).toBe('3');
});
46% fewer lines

SSE Auto-Reconnect Flow

Connected

Receiving events normally

Connection Lost

Server drop or network error

⚙️

Wait

Retry interval (default 3s)

↪️

Reconnect

GET with Last-Event-ID

🔔

Server Resume

Replay missed events

Caught Up

Normal streaming resumes

6. Scenario: Connection State Transitions

The EventSource object exposes a readyState property with three possible values: CONNECTING (0), OPEN (1), and CLOSED (2). Your application should react to these states by showing connection indicators, disabling features that depend on real-time data during disconnections, and re-enabling them when the connection recovers. Testing these transitions requires observing the readyState value at specific moments during the connection lifecycle, which means injecting JavaScript into the page context.

4

Connection State Transitions

Moderate

Goal

Verify that the UI correctly reflects all three EventSource states: connecting (loading spinner), open (green indicator), and closed/reconnecting (yellow indicator with retry countdown).

Playwright Implementation

connection-state.spec.ts

7. Scenario: Backpressure and Slow Consumers

Backpressure occurs when the server sends events faster than the client can process them. In a standard SSE setup, the server writes to a TCP socket. If the client's receive buffer is full (because the browser is busy rendering previous events or the JavaScript event loop is blocked), the TCP window shrinks, and the server's res.write() call will either block or return false, depending on the Node.js stream implementation. Testing this requires sending a burst of events and measuring whether the client eventually processes all of them without dropping any, and whether the server handles the write backpressure without crashing.

5

Backpressure and Slow Consumers

Complex

Goal

Send a burst of 100 events in rapid succession, verify the client eventually receives all of them (order preserved), and confirm no events are dropped or duplicated.

Playwright Implementation

backpressure.spec.ts

8. Scenario: AI Token Streaming via SSE

One of the most common modern uses of Server-Sent Events is streaming AI model responses token by token. Services like OpenAI, Anthropic, and Cohere use SSE to deliver completion tokens in real time, allowing the UI to render text incrementally as the model generates it. Testing this pattern introduces unique challenges: the events arrive at irregular intervals determined by model inference speed, each event contains a small payload (often a single token or a few words), and the stream terminates with a special [DONE] sentinel or a finish_reason field. Your test needs to verify the progressive rendering, the final assembled response, and the correct handling of the stream termination signal.

6

AI Token Streaming via SSE

Complex

Goal

Intercept the SSE stream from an AI completion endpoint, verify tokens render progressively in the UI, confirm the final assembled text matches the expected output, and verify the stream terminates cleanly.

Playwright Implementation

ai-streaming.spec.ts

What to Assert Beyond the UI

  • Tokens render incrementally (not all at once after stream ends)
  • The streaming indicator is visible during active streaming
  • The [DONE] sentinel cleanly terminates the stream
  • Mid-stream errors show a user-friendly message without crashing the page
  • The assembled response text matches the concatenation of all token deltas

AI Token Streaming: Playwright vs Assrt

test('renders AI tokens via SSE', async ({ page }) => {
  await page.route('**/api/chat/completions',
    async (route) => {
      const tokens = ['Hello', ' world', '!'];
      const encoder = new TextEncoder();
      const body = new ReadableStream({
        async start(controller) {
          for (const t of tokens) {
            controller.enqueue(encoder.encode(
              `data: ${JSON.stringify({
                choices: [{ delta: { content: t } }],
              })}\n\n`));
            await new Promise(r => setTimeout(r, 100));
          }
          controller.enqueue(
            encoder.encode('data: [DONE]\n\n'));
          controller.close();
        },
      });
      await route.fulfill({
        status: 200,
        headers: { 'Content-Type': 'text/event-stream' },
        body: body as any,
      });
    });
  await page.goto('/chat');
  await page.getByPlaceholder('Type a message...')
    .fill('Say hello');
  await page.getByRole('button', { name: /send/i }).click();
  await expect(page.getByTestId('ai-response'))
    .toHaveText('Hello world!', { timeout: 10000 });
});
57% fewer lines

9. Common Pitfalls That Break SSE Test Suites

After analyzing GitHub issues, Stack Overflow threads, and Playwright discussion boards related to SSE testing, these are the most frequent failure modes that teams encounter. Each one has caused real production test suite failures.

Pitfall 1: Asserting on Events Before the Connection Opens

The most common SSE test failure is a race condition: the test sends an event to the server before the EventSource connection is fully established. The event arrives at the server, gets written to the response stream, but the client has not yet attached its onmessage listener. The fix is to always wait for a connection indicator in the UI or use page.evaluate() to check readyState === 1 before sending test events.

Pitfall 2: Using page.waitForResponse() for SSE Streams

Playwright's page.waitForResponse() resolves when the response headers arrive, not when the stream ends. For SSE connections, the response headers arrive immediately, but events continue flowing for the lifetime of the connection. Teams often write await page.waitForResponse('**/events') and expect it to wait for events to arrive. It does not. Instead, wait for visible UI updates triggered by the events, or use a page.evaluate() polling pattern to check for received event data.

Pitfall 3: Not Cleaning Up EventSource Connections Between Tests

EventSource connections persist until explicitly closed or until the page navigates away. If your test creates a connection in one test and another test navigates to the same page, you can end up with duplicate connections. Each connection receives the same events, causing your UI to render duplicate entries. Always call eventSource.close() in your test teardown, or use page.close() to ensure clean state.

Pitfall 4: Ignoring CORS Headers for Cross-Origin SSE

When your SSE endpoint is on a different origin than your frontend (common in microservice architectures), the EventSource request is subject to CORS. Unlike fetch(), EventSource does not support custom request headers. If your SSE endpoint requires an Authorization header, you cannot use EventSource at all; you need to fall back to fetch() with a streaming reader. Tests that pass locally (same origin) may fail in staging (cross-origin) because of missing Access-Control-Allow-Origin headers.

Pitfall 5: Hardcoded Timeouts for Reconnect Assertions

The default SSE reconnect delay is approximately 3 seconds, but the server can change it with the retry: field. Tests that hardcode await page.waitForTimeout(3000) before checking for reconnection will be flaky on slow CI runners and wasteful on fast ones. Instead, use Playwright's polling assertions: await expect(locator).toHaveText('Connected', { timeout: 10_000 }). This polls rapidly and resolves as soon as the condition is met, without an arbitrary sleep.

Common SSE test failure output

SSE anti-patterns to avoid

  • Sending events before verifying the connection is open
  • Using page.waitForResponse() to wait for SSE events
  • Leaving EventSource connections open between tests
  • Ignoring CORS headers for cross-origin SSE endpoints
  • Hardcoding sleep timers instead of polling assertions
  • Testing only onmessage and missing custom event types
  • Not testing the reconnect path with Last-Event-ID

10. Writing These Scenarios in Plain English with Assrt

The Playwright code in sections 3 through 8 totals several hundred lines of TypeScript. Each scenario requires understanding EventSource interception, route mocking, page.evaluate injection, and polling assertions. Assrt lets you express the same test intent in plain English. You describe what should happen, Assrt compiles it into the Playwright code you saw above, and commits it to your repo as real, runnable tests.

scenarios/sse-complete.assrt

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When the EventSource API behavior changes across browser versions, or when your SSE endpoint restructures its event format, Assrt detects the failure, analyzes the new behavior, and opens a pull request with the updated test code. Your scenario files stay untouched.

Start with the basic stream happy path. Once it is green in your CI, add the custom event type scenario, then the auto-reconnect test, then the connection state transitions, then the backpressure burst test, then the AI streaming scenario. In a single afternoon you can have complete Server-Sent Events test coverage that most production applications never achieve by hand.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk