End-to-End Testing SSE with Playwright Permalink to this section

Part of Testing & Load Testing SSE Endpoints, under Backend Stream Generation & Connection Management.

Unit tests with a fake EventSource prove your reducer is correct. They cannot prove that the browser’s real EventSource dispatches your named events, reconnects with the right Last-Event-ID after the server closes the connection, sends cookies with withCredentials, or that the UI shows “reconnecting” at the right moment. Playwright drives a real browser, so it can. This guide builds deterministic end-to-end tests for streaming UIs: controlling what the server sends and when, forcing reconnects, and asserting on what the user sees.

Symptom & Developer Intent Permalink to this section

  • Streaming features pass every unit test and break in the browser: listeners never fire, or reconnects lose events.
  • E2E tests for live features are flaky, passing or failing depending on timing.
  • Tests use page.waitForTimeout(3000) and still fail on slow CI runners.
  • Nobody has tested what the UI shows during a reconnect.
  • Tests leave streams open and interfere with each other.

The intent is a small set of reliable browser tests for streaming behaviour: first render from a stream, live updates, reconnect with resume, a fatal error state, and cleanup on navigation.

Root Cause Analysis Permalink to this section

Flakiness in streaming E2E tests has two sources. The test cannot control when the server sends events, so it sleeps and hopes; and it cannot observe when the browser has received them, so it sleeps again. Both are fixed by making the test the author of the events and waiting on observable UI state instead of time.

The test controls the stream, the page shows the result Flow from a Playwright test posting to a test-only control endpoint, the server publishing an event, the browser's EventSource receiving it, and the test waiting on a DOM assertion. The test controls the stream, the page shows the result Playwright test request.post trigger Control endpoint test-only publish publish SSE server writes frame stream Browser page real EventSource assert expect(locator) auto-retrying
No sleeps. The test triggers exactly the event it needs, then waits for the UI state that event should produce, with an assertion that retries until it passes or times out.

Two server-side tools make this possible: a test control endpoint that publishes an arbitrary event (enabled only in test builds), and a disconnect endpoint that closes all open streams, forcing the browser to reconnect. Alternatively, Playwright’s page.route can fulfil the stream request with a canned body, which is ideal for testing parsing and reconnect without any server logic.

Step-by-Step Resolution Permalink to this section

Step 1 — Add test-only control endpoints Permalink to this section

// Enabled only when NODE_ENV === 'test'. Never deploy these.
if (process.env.NODE_ENV === 'test') {
  app.post('/__test/publish', express.json(), (req, res) => {
    hub.publish(req.body);                       // { id, event, data }
    res.sendStatus(204);
  });
  app.post('/__test/drop-streams', (_req, res) => {
    for (const s of openStreams) s.destroy();    // simulate a network drop
    res.sendStatus(204);
  });
}

Step 2 — Assert on the first render and live updates Permalink to this section

// notifications.spec.js
import { test, expect } from '@playwright/test';

test('a published notification appears in the bell', async ({ page, request }) => {
  await page.goto('/app');
  await expect(page.getByTestId('stream-status')).toHaveText('live');   // stream is open

  await request.post('/__test/publish', {
    data: { id: '101', event: 'notification', data: JSON.stringify({ id: 101, text: 'Build passed' }) },
  });

  await expect(page.getByRole('status')).toContainText('Build passed');
  await expect(page.getByTestId('bell-count')).toHaveText('1');
});

Waiting for the status indicator to read live before publishing guarantees the subscription exists, which removes the most common race. expect(locator) retries until the assertion passes, so there is no sleep anywhere.

Step 3 — Test reconnect and resume Permalink to this section

test('reconnects and receives events published during the drop', async ({ page, request }) => {
  await page.goto('/app');
  await expect(page.getByTestId('stream-status')).toHaveText('live');
  await request.post('/__test/publish', { data: { id: '1', event: 'notification', data: '{"id":1,"text":"first"}' } });
  await expect(page.getByText('first')).toBeVisible();

  // Record the Last-Event-ID the browser sends on its next connection.
  const reconnect = page.waitForRequest((r) => r.url().endsWith('/api/stream') && r.headers()['last-event-id']);
  await request.post('/__test/drop-streams');
  await expect(page.getByTestId('stream-status')).toHaveText('reconnecting');
  await request.post('/__test/publish', { data: { id: '2', event: 'notification', data: '{"id":2,"text":"second"}' } });

  expect((await reconnect).headers()['last-event-id']).toBe('1');
  await expect(page.getByText('second')).toBeVisible();              // replayed after reconnect
  await expect(page.getByText('first')).toHaveCount(1);              // not duplicated
});

This single test proves that the real EventSource resends Last-Event-ID, that the server replays, that the client deduplicates, and that the UI shows the reconnecting state.

The reconnect test step by step Sequence diagram of the reconnect test: the test publishes event 1, drops streams, publishes event 2, the browser reconnects with Last-Event-ID 1, and receives event 2 by replay. The reconnect test step by step Test Server Browser publish id 1 id 1 drop streams publish id 2 (browser offline) reconnect Last-Event-ID: 1 id 2 (replay)
Event 2 is published while the browser is disconnected. It can only appear on the page if Last-Event-ID and replay both work.

Step 4 — Test parsing and fatal errors with page.route Permalink to this section

For client-only concerns, fulfil the stream from the test with a canned body. When the body ends, EventSource reconnects — so the handler can serve different bodies on successive requests:

test('a 401 on reconnect shows the signed-out state', async ({ page }) => {
  let calls = 0;
  await page.route('**/api/stream', (route) => {
    calls += 1;
    if (calls === 1) {
      return route.fulfill({
        status: 200,
        headers: { 'Content-Type': 'text/event-stream' },
        body: 'retry: 100\n\nid: 1\nevent: notification\ndata: {"id":1,"text":"hello"}\n\n',
      });
    }
    return route.fulfill({ status: 401, body: '' });          // EventSource treats this as fatal
  });

  await page.goto('/app');
  await expect(page.getByText('hello')).toBeVisible();
  await expect(page.getByTestId('stream-status')).toHaveText('signed out');
});

The short retry: 100 keeps the test fast. page.route delivers the body all at once, so it is for parsing and state transitions, not for timing behaviour.

Step 5 — Check cleanup on navigation Permalink to this section

test('leaving the page closes the stream', async ({ page }) => {
  await page.goto('/app');
  const closed = page.waitForEvent('requestfinished', (r) => r.url().endsWith('/api/stream'))
    .catch(() => null);
  await page.goto('/settings');                               // a route without the stream
  const streams = await page.evaluate(() => window.__openEventSources?.size ?? 0);
  expect(streams).toBe(0);
});

Expose a small test hook (window.__openEventSources) from the stream module in test builds so leaks are directly observable, as discussed in preventing EventSource memory leaks in React.

Step 6 — Test the watchdog and offline state with fake time Permalink to this section

Silence watchdogs and backoff timers run on intervals of seconds to minutes. Playwright’s clock API lets a test install fake timers in the page and fast-forward them, so a test of “reconnect after 45 seconds of silence” takes milliseconds:

test('silence watchdog forces a reconnect', async ({ page, request }) => {
  await page.clock.install();
  await page.goto('/app');
  await expect(page.getByTestId('stream-status')).toHaveText('live');

  const reopened = page.waitForRequest((r) => r.url().endsWith('/api/stream'));
  await request.post('/__test/pause-heartbeats');            // server stops sending comments
  await page.clock.fastForward('00:46');                      // past 2 × 20 s heartbeat + margin
  await reopened;                                             // the watchdog reconnected
  await expect(page.getByTestId('stream-status')).toHaveText('live');
});

For the offline indicator, context.setOffline(true) cuts the page’s network; assert the indicator appears, restore with setOffline(false), and assert it disappears once the stream reopens. Together these tests cover the parts of the connection UX that users notice most and that are hardest to reproduce by hand.

Validation & Monitoring Permalink to this section

Streaming behaviours and the test that covers each Matrix of five browser streaming behaviours and whether they are covered by unit tests with a fake EventSource or by Playwright tests with a real one. Streaming behaviours and the test that covers each Behaviour Fake EventSource unit test Playwright E2E Reducer applies events yes indirectly Named event dispatch assumed proven Last-Event-ID on reconnect assumed proven Fatal status stops retries assumed proven Cleanup on navigation partly proven
The bottom three rows cannot be proven without a real browser, which is why a handful of E2E tests are worth their cost.

Run the streaming specs with --repeat-each=20 locally before trusting them in CI; any flake that appears is a missing wait on an observable state. In CI, keep streaming specs in their own project with fullyParallel: false if they share a server, or give each worker its own server instance.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Can Playwright stream a mocked response slowly?

route.fulfill sends the whole body at once. For timing-sensitive behaviour, run a real test server and drive it through control endpoints; use route.fulfill for parsing and state transitions.

How do I simulate the browser going offline?

context.setOffline(true) cuts the network for the page. Combined with a server-side drop it lets you test the offline indicator and the reconnect after setOffline(false).

Should E2E tests run against a real broker?

Usually not. An in-memory hub behind the same interface keeps tests fast and deterministic; broker integration is better covered by service-level integration tests.

Can I read the EventStream panel from Playwright?

Not directly, but you can observe the stream request and its headers with waitForRequest, and expose received events through a test hook on window if you need to assert on raw frames.