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.
“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
Server-Sent Events End-to-End Flow
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.
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.
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.
Basic EventSource Stream Happy Path
StraightforwardGoal
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
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');
});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.
Custom Event Types and Routing
ModerateGoal
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
What to Assert Beyond the UI
- Typed events do not trigger the generic
onmessagehandler - Each event type dispatches only to its registered
addEventListenercallback - Unknown event types are silently ignored (no errors in the console)
- The
event.typeproperty in the callback matches the server-sent type
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.
Auto-Reconnect with Last-Event-ID
ComplexGoal
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-IDheader for replay
Playwright Implementation
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');
});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.
Connection State Transitions
ModerateGoal
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
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.
Backpressure and Slow Consumers
ComplexGoal
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
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.
AI Token Streaming via SSE
ComplexGoal
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
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 });
});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.
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.
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
How to Test Ably Realtime
A practical guide to testing Ably Realtime messaging with Playwright. Covers token auth...
How to Test AI Chat Streaming UI
A practical guide to testing AI chat streaming interfaces with Playwright. Covers...
How to Test Collaborative Cursors
A practical guide to testing collaborative cursors with Playwright. Covers Liveblocks and...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.