Root cause #
SSE is a single HTTP response with Content-Type: text/event-stream that stays open while the server writes data: frames separated by blank lines. The browser parses each frame into a message event as it arrives. Playwright’s page auto-waiting does nothing for this: it waits for elements, not for the third push, so a test asserting “the counter shows 2 after two ticks” depends entirely on when the server emits — which under load or a shared backend is nondeterministic.
Because SSE is plain HTTP, you can intercept it with page.route and fulfill it with a pre-baked body containing exactly the events you want. The browser parses that body into discrete events immediately, so the sequence is fixed and the assertions between events are stable. The trade-off is that a single fulfilled body collapses inter-event timing — all events arrive at once — so when arrival cadence itself matters, pair the stub with fake timers.
Step-by-step fix #
1. Fulfill the SSE endpoint with a scripted body #
Intercept the events route and return a text/event-stream body with your frames.
// tests/sse.spec.ts — fulfill the SSE endpoint with a fixed event stream
import { test, expect } from '@playwright/test';
test('renders scripted price ticks', async ({ page }) => {
await page.route('**/events/prices', async (route) => {
const body =
'data: {"symbol":"ACME","price":10}\n\n' +
'data: {"symbol":"ACME","price":11}\n\n'; // trade-off: all frames arrive at once — deterministic, no inter-event timing
await route.fulfill({ status: 200, contentType: 'text/event-stream', body });
});
await page.goto('/prices');
await expect(page.getByTestId('price')).toHaveText('11'); // last tick wins, deterministically
});
2. Assert per-event when order is the subject #
When the test verifies intermediate states, emit fewer frames per stub or split the assertion so each event’s effect is checked before the next matters.
// Assert the UI reflected the first tick before the second — order is the invariant.
await expect(page.getByTestId('log')).toContainText('10');
await expect(page.getByTestId('log')).toContainText('11');
3. Add fake timers when arrival cadence matters #
If the UI debounces or throttles by arrival time, a single body cannot exercise it. Drive a controllable clock alongside the stream, exactly as in simulating slow 3G conditions in Playwright.
// Install a page clock so arrival-time logic advances deterministically.
await page.clock.install();
await page.clock.fastForward(1000); // advance exactly one cadence window
Pitfalls #
- Missing the blank-line separator. Without
\n\nbetween frames the browser treats them as one event. Mitigation: end every frame with a blank line. - Expecting inter-event delays from one body. A single fulfilled body delivers all frames at once. Mitigation: use fake timers for cadence, or multiple stubbed responses.
- Registering the route after navigation. The first connection escapes the mock. Mitigation:
page.routebeforepage.goto. - Not closing the stream. A never-ending body can hang teardown. Mitigation: fulfill a finite body so the response completes.
- Asserting on a mid-parse state. Chaining a bare
.thenskips retryability. Mitigation: use web-firstexpectassertions that retry.
Reliability targets #
| Metric | Target | How to hit it |
|---|---|---|
| SSE-driven assertion flake | < 0.5% |
Fulfilled text/event-stream body |
| Inter-event timing determinism | ±0ms |
Batched body or fake timers |
| Real-network SSE calls in mocked tests | 0 |
Route before navigation |
| CI pass rate on stream specs | ≥ 99.5% |
Ordered assertions, finite body |
Frequently Asked Questions #
Why not use a WebSocket mock for SSE?
SSE is one-way over plain HTTP, so page.route fulfilment is simpler and needs no socket override. Reserve the WebSocket approach for bidirectional streams.
How do I test reconnection on stream end? Fulfill a short body so the stream closes, then let the client’s reconnect logic issue a second request your route also fulfills — asserting the UI recovered.
Can I stream events with real delays?
Not from one fulfilled body. Combine the stub with page.clock to advance arrival-time logic deterministically instead of using real waits.
Ordering, Duplicates and the States Between Events #
A stream delivers a sequence, and the interesting behaviour is what the interface does between events rather than after the last one.
Three scenarios are worth covering deliberately, and all three are trivial to produce with a controlled stream and effectively impossible to produce on demand from a real feed. Out-of-order arrival, where a stale update lands after a fresher one and must not overwrite it — the classic cause of a value that flickers backwards. Duplicates, where the same event arrives twice after a reconnection and a naive handler double-counts. And bursts, where dozens of events arrive in one frame and the interface must coalesce rather than re-render for each.
Each of these maps to a branch in the application’s reducer, and each produces a distinctive production symptom: a total that decreases, a list with repeated rows, an interface that becomes unresponsive under load. Testing them costs a few lines once the stream is under the test’s control.
The assertion that matters is usually about the settled state after the sequence rather than about intermediate renders — the total is correct, the list has no duplicates, the interface remained responsive — since the intermediate frames are implementation detail the component is free to change.
Why SSE Is Easier to Mock Than WebSockets #
Server-sent events travel over an ordinary HTTP response whose body never ends, which has a convenient consequence: request interception can serve them. A route handler can fulfil the request with a body containing formatted events, and the browser’s EventSource will parse and dispatch them exactly as it would from a real server — no constructor replacement, no local server, no separate transport to manage.
That places SSE testing much closer to ordinary response mocking than to socket testing. The format is the only thing to get right: each event is a set of data: lines terminated by a blank line, with optional event: and id: fields, and a missing blank line means the browser buffers the event indefinitely rather than dispatching it — which presents as “the mock produced nothing”.
// SSE is a never-ending HTTP body; format matters more than machinery.
// Trade-off: a fulfilled body delivers all events at once, which is fine for
// rendering assertions and wrong if the test cares about arrival timing.
await page.route('**/api/stream', (route) =>
route.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
body: [
'event: price',
'data: {"symbol":"ACME","value":42.5}',
'',
'event: price',
'data: {"symbol":"ACME","value":43.0}',
'',
].join('\n'),
}));
Where arrival timing matters — asserting that the interface updates progressively rather than all at once — a fulfilled body is insufficient and a small local endpoint that writes events with delays is the honest approach.
Reconnection Is the Behaviour That Breaks #
The part of SSE that fails in production is the part a fulfilled body cannot exercise: EventSource reconnects automatically when a connection drops, and it sends a Last-Event-ID header so the server can resume from where the client left off.
That protocol carries real product risk. A server that ignores the resume header replays events the client already processed, producing duplicated rows or double-counted totals. A client that does not deduplicate has the same problem from the other side. And an application that treats a reconnection as a fresh start may silently drop everything that happened during the gap.
Testing it requires a connection that actually closes, which means a small local endpoint rather than a fulfilled response. Close the connection after a few events, assert that the client reconnects, check that the resume header carries the last identifier it saw, and verify that the interface neither duplicates nor loses data across the gap. Those three assertions cover the failure modes that account for most real-time data bugs, and none of them is reachable with a static body.
The pragmatic split mirrors the socket case: use route fulfilment for rendering and parsing behaviour, where determinism matters and fidelity does not, and a real endpoint for connection lifecycle, where fidelity is the entire point.
Format Before Mechanism #
Most SSE mocking problems are formatting problems: a missing blank line between events, a data: field split incorrectly, or a content type the browser does not accept as a stream. Checking the raw body before debugging the application saves a surprising amount of time.
Setting an explicit event identifier on each event makes the resume behaviour testable, because the client echoes the last identifier it saw when it reconnects — and asserting on that header is the only way to know the resume path works.