Server-Sent Events are simple until you test them. The browser opens a long-lived HTTP connection, the server emits text/event-stream, and the UI updates as events arrive. The trouble starts when you need to verify three things at once: the stream reconnects after a drop, the retry delay behaves as expected, and partial renders do not corrupt the visible state.

That combination creates timing flakes because the test is observing both application state and network state. The fix is not to wait longer. The fix is to control the stream, inject failures at known points, and log the exact event sequence so you can distinguish a stale replay from a real rendering bug.

The goal is not to prove the network is stable. The goal is to prove your UI behaves correctly when the network is not.

For the browser API itself, the relevant primary references are the EventSource section of the HTML standard and MDN’s Using server-sent events guide. If you are using Playwright for browser automation, its network APIs and locators are the right tools for this kind of test because they let you observe traffic and assert UI state separately.

What you are actually testing

Before writing a test, separate the behavior into three layers:

  1. Transport behavior, whether the browser reconnects after the SSE connection drops.
  2. Protocol behavior, whether the stream includes id:, event:, and retry: fields in the shape your client expects.
  3. Render behavior, whether the UI renders incremental updates, duplicate messages, and resumed messages correctly.

Those layers fail differently. A transport bug may show up as silence after a disconnect. A protocol bug may show up as the client ignoring a resume token. A render bug may show up as duplicated rows, stale totals, or a partially updated card that never reconciles after the next event.

If your tests blur these together, every failure looks like “the stream is flaky.” That makes triage slow and hides the real defect.

Minimal app fixture

Use a tiny fixture that you can run locally and in CI. The point is not to mirror production exactly, it is to expose the behavior under test with as little unrelated code as possible.

A simple browser page might look like this:

<div>
  <div id="status">connecting</div>
  <ul id="feed"></ul>
</div>
<script>
  const status = document.getElementById('status');
  const feed = document.getElementById('feed');
  const seen = new Set();
  let lastId = null;

function renderMessage(msg) { if (seen.has(msg.id)) return; seen.add(msg.id); lastId = msg.id; const li = document.createElement(‘li’); li.textContent = ${msg.id}: ${msg.text}; feed.appendChild(li); }

function connect() { const es = new EventSource(/events?since=${encodeURIComponent(lastId ?? '')});

es.onopen = () => status.textContent = 'open';
es.onerror = () => status.textContent = 'reconnecting';
es.onmessage = (event) => renderMessage(JSON.parse(event.data));   }

connect(); </script>

This fixture intentionally stores the last seen message id and ignores duplicates. That lets you test the two important questions separately:

  • Did the browser reconnect?
  • Did the app deduplicate resumed messages?

If your real UI depends on stream replay, you can keep the same structure and change the reconciliation logic, but do not start with the full app. A thin fixture makes it much easier to inject drops and inspect the resulting DOM.

Build a controllable SSE server

For reproducible SSE testing in browser automation, the server should be able to emit a known sequence, pause, and close the stream on command. In Node, that can be done with a small HTTP handler.

import http from 'http';

http.createServer((req, res) => { if (req.url.startsWith(‘/events’)) { res.writeHead(200, { ‘Content-Type’: ‘text/event-stream’, ‘Cache-Control’: ‘no-cache’, ‘Connection’: ‘keep-alive’ });

const messages = [
  { id: '1', text: 'alpha' },
  { id: '2', text: 'beta' },
  { id: '3', text: 'gamma' }
];

let i = 0;
const timer = setInterval(() => {
  const m = messages[i++];
  if (!m) return;
  res.write(`id: ${m.id}\n`);
  res.write(`data: ${JSON.stringify(m)}\n\n`);
  if (m.id === '2') {
    clearInterval(timer);
    setTimeout(() => res.end(), 50);
  }
}, 100);

req.on('close', () => clearInterval(timer));
return;   }

res.writeHead(404).end(); }).listen(3000);

This does two useful things for testing:

  • It sends deterministic ids so the client can resume from a known position.
  • It closes the connection at a predictable point, which forces reconnection.

If your production backend already supports Last-Event-ID, align the fixture with that behavior. The HTML standard describes how EventSource uses Last-Event-ID during reconnects, so your test should reflect that contract rather than inventing a different one.

How to inject failure without guessing timings

The most reliable failure injection is not a sleep, it is an explicit stream termination. You can also add a second layer of control by aborting network traffic from the test runner if you need to verify browser-side reconnection behavior independent of server logic.

With Playwright, you can block or abort specific requests, then observe the UI after the connection drops:

import { test, expect } from '@playwright/test';
test('reconnects after SSE drop and avoids duplicates', async ({ page }) => {
  await page.route('**/events**', async route => {
    const request = route.request();
    if (request.url().includes('since=2')) {
      await route.abort();
      return;
    }
    await route.continue();
  });

  await page.goto('http://localhost:3000');
  await expect(page.locator('#status')).toHaveText(/open|reconnecting/);
  await expect(page.locator('#feed li')).toHaveCount(2);
});

Two notes matter here.

First, aborting the request from the test runner is useful when you want to simulate a network drop without changing the app code.

Second, you should not assert the exact browser reconnect timing unless your application contract depends on it. The browser handles retries using its own EventSource implementation, and the interval can vary depending on the retry: field and the browser behavior. If the app only needs “reconnect eventually,” assert the eventual state, not a millisecond-perfect delay.

Testing reconnect backoff without chasing flakes

If your product sets a custom retry: value, test that value at the protocol boundary, then verify a reasonable elapsed range at the browser boundary.

A practical way to do this is:

  • Emit retry: 5000 from the fixture.
  • Drop the connection after one or two events.
  • Observe that the client enters a reconnecting state.
  • Assert that the next request does not arrive immediately.

Do not write a test that fails if reconnect takes 4.8 seconds instead of 5.0 seconds. That is not a product bug, it is timing noise. What you care about is whether the client honors a backoff policy and whether the UI remains honest about the connection state while waiting.

A good SSE test asserts state transitions, not exact scheduler behavior.

If you need to validate that your own reconnect wrapper applies exponential backoff on top of native EventSource, test the wrapper as a separate unit with a mocked clock. Then test the browser-visible integration only for coarse transitions, such as open -> reconnecting -> open.

Partial stream rendering tests

Partial stream rendering is where many live data feed QA bugs hide. The UI may render the first chunk correctly, then fail on the second chunk because it replaces the list instead of appending, or it may merge chunks and duplicate the last item after reconnect.

Test these cases explicitly:

1. Incremental append

After each event, the visible feed should grow by one item. This catches accidental rerenders that wipe previous items.

2. Duplicate replay after reconnect

If the server re-sends the last event after reconnect, the UI should ignore it when the id matches a previously seen event.

3. Out-of-order resume

If the resumed stream starts with an older id, the UI should decide whether to ignore, replace, or merge based on your product rules. Do not assume there is one correct answer, define it in advance.

4. Stale partial render

If the stream disconnects while a card is half updated, the next successful event should either complete the render or reset the component to a valid state.

A good assertion pattern is to inspect the final DOM plus the log of seen ids. For example:

const items = await page.locator('#feed li').allTextContents();
expect(items).toEqual([
  '1: alpha',
  '2: beta',
  '3: gamma'
]);

If the UI includes optimistic placeholders or skeleton rows, make those explicit in the fixture. Do not let a loading spinner hide an incorrect merge result.

What to log when the stream resumes with stale or duplicated events

When the stream resumes and the UI looks wrong, you need a compact log that makes the failure obvious. Capture at least these fields:

  • connection attempt number
  • timestamp of each open, message, and error
  • event id, event type, and payload hash
  • Last-Event-ID or equivalent resume token
  • whether the message was appended, ignored, or replaced
  • current UI state after each message

A minimal client-side log structure can look like this:

type StreamLog = {
  ts: number;
  kind: 'open' | 'error' | 'message';
  id?: string;
  action?: 'append' | 'ignore' | 'replace';
};

If the same id appears twice, the log should show whether the duplicate came from the server replay or from the UI processing the same message more than once. That distinction saves time, because a duplicated server event and a duplicated DOM update are different bugs.

A short checklist for reproducible SSE tests

Use this checklist before blaming timing:

  • Fix the event sequence in the fixture.
  • Force a connection close at a known point.
  • Decide whether you are testing native reconnect behavior or your own backoff wrapper.
  • Assert eventual state, not exact retry timing, unless timing is a contract.
  • Record event ids and resume tokens.
  • Verify duplicate suppression at the DOM boundary.
  • Keep the fixture separate from production data sources.
  • Make every failure readable from logs alone.

When a failure is real, not just transient

Some failures are not flakes. A few examples are worth calling out because they usually indicate a product defect rather than test noise:

  • The stream reconnects, but the UI never returns from reconnecting.
  • The resumed stream starts at the wrong event id.
  • The client appends duplicates after replay.
  • A partial render leaves the DOM in an impossible state, such as a total count that does not match the visible rows.
  • The page recovers only after a manual reload, which means reconnect logic is incomplete.

Those are not network mysteries. They are observable behavior differences, and your test should name them directly.

Bottom line

To test server sent events reconnect behavior without timing flakes, control the stream, not the clock. Use a small fixture, inject a known disconnect, and assert browser-visible state transitions plus message ids. If you separate transport, protocol, and render checks, you can tell the difference between a transient reconnect and a real partial-render bug, which is exactly what live dashboards and collaboration UIs need.

FAQ

How do I test SSE reconnect behavior in browser automation?

Use a controllable fixture that closes the stream at a known point, then assert that the page returns to an open state and processes the resumed events correctly. Playwright network interception can simulate the drop, but the test should still validate the visible UI state.

Should I assert the exact reconnect delay?

Usually no. Assert that reconnect happens and that your app honors the intended retry policy. Exact millisecond timing is often browser-dependent and too brittle for end-to-end tests.

How do I catch duplicate events after reconnect?

Track event ids in the client or fixture, then assert that the DOM contains each id once. If the server intentionally replays the last event, your test should verify that the UI deduplicates it.

What should I log when an SSE stream resumes incorrectly?

Log timestamps, event ids, resume tokens, connection attempts, and the UI action taken for each message, such as append, ignore, or replace. That makes it easier to separate replay behavior from render bugs.

Is a real backend required for SSE testing?

No. A small local server is usually better for reproducibility. Use the real backend only when you need to validate production replay semantics, authentication, or infrastructure-specific behavior.