Article · Network & API Mocking for Reliable Tests

Mocking Server-Sent Events in Playwright

A Server-Sent Events stream pushes updates over one long-lived HTTP response, so a test that waits for "the third event" is at the mercy of whenever the real server decides to emit — the definition of a timing race. This page expands GraphQL & WebSocket Mocking for Reliable Tests within Network & API Mocking for Reliable Tests, showing how to fulfill an SSE endpoint with a scripted event stream so the UI receives deterministic, ordered updates.

13 sections URL: /network-api-mocking-for-reliable-tests/graphql-and-websocket-mocking/mocking-server-sent-events-in-playwright/
Live SSE versus a scripted stream A live stream emits events on the server's schedule; a fulfilled text/event-stream body delivers a fixed, ordered set of events. Live: server-timed unpredictable gaps → flake Scripted: fulfilled body tick 1 tick 2 delivered as discrete events, in order
A fulfilled `text/event-stream` body replaces server-timed emission with a fixed, ordered set of events.

Root cause #

SSE is one long HTTP response The server holds the response open and writes data frames; the browser parses each into a message event. open responsetext/event-stream data: frames message events
Because it is plain HTTP, route.fulfill can replace the server-timed emission entirely.

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.

One body, many parsed events A single fulfilled response body carrying multiple data frames is parsed by the browser into discrete message events. route.fulfilltext/event-stream browser parses frames message events
The blank-line separator between frames is what tells the browser where one event ends and the next begins.
// 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 #

Frame separators define events Each event must end with a blank line, or the browser merges frames into one event. no blank lineframes merge → 1 event data: ...\n\ndiscrete, ordered events
The blank-line separator is what makes the frames parse as distinct events.
  • Missing the blank-line separator. Without \n\n between 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.route before page.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 .then skips retryability. Mitigation: use web-first expect assertions that retry.

Reliability targets #

SSE-mocking scorecard Targets for stream flake, timing determinism, real-network calls, and CI pass rate. < 0.5%stream flake ±0msdeterminism 0real-net calls ≥ 99.5%CI pass
A fulfilled body removes the server-timing variance entirely.
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.