Test Server-Sent Events (SSE) flows, one-way server-to-client push only (not bidirectional, use websocket-tests for client-to-server messaging): `EventSource` API on the browser side (`onmessage`, `onerror`, `readyState` 0/1/2), event stream format (`data:`, `event:`, `id:`, `retry:`), `Last-Event-ID` reconnect-with-replay header, content-type `text/event-stream`, and HTTP/1.1 connection-pool limits. Use Playwright for browser-side, raw HTTP client for server-side stream tests; k6 load testing (concurrent-stream capacity, connection churn, HTTP/1.1 vs HTTP/2 ceiling) lives in references/sse-load.md. Use when a feature pushes updates over `text/event-stream` and the reconnect interval, `Last-Event-ID` replay, per-origin connection ceiling, or concurrent-stream capacity has no coverage.
69
87%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Deeper recipes for reconnect-with-replay, retry interval, 204
disable, and the HTTP/1.1 connection-pool ceiling. All behavior is
per the WHATWG SSE spec.
Last-Event-IDOn disconnect the client automatically reconnects with
Last-Event-ID: <last-id-seen>. The server uses it to replay missed
events.
Server pseudocode:
def stream(request):
last_id = int(request.headers.get("Last-Event-ID", "0"))
for evt in fetch_events_since(last_id):
yield f"id: {evt.id}\nevent: {evt.type}\ndata: {evt.json()}\n\n"Test (raw HTTP client, simulates reconnect):
import requests
# parse_until_count(r, n): read the stream until n complete events parse, return them
def test_replay_via_last_event_id():
# First connection - read 5 events, then close
with requests.get("http://localhost:8080/stream", stream=True) as r:
events = parse_until_count(r, 5)
last_id = events[-1]["id"]
# Reconnect with Last-Event-ID
headers = {"Last-Event-ID": last_id}
with requests.get("http://localhost:8080/stream", stream=True, headers=headers) as r:
replay = parse_until_count(r, 1)
assert int(replay[0]["id"]) > int(last_id)Verify: assert the reconnect request carries Last-Event-ID and the
first replayed id is greater than the last one seen; if it is not,
the server is not persisting event IDs - fix the store before relying
on replay.
retry:)Server hints at the reconnect interval:
retry: 10000The browser waits >= 10s before reconnecting. Test that it honors the hint:
test('client honors retry: 10000 on disconnect', async ({ page }) => {
// Server emits retry: 10000, then closes
const reconnectMs = await page.evaluate(() => {
return new Promise<number>((resolve) => {
const es = new EventSource('/api/stream-with-retry');
let openTime = 0;
es.onopen = () => {
if (openTime === 0) {
openTime = performance.now();
} else {
es.close();
resolve(performance.now() - openTime);
}
};
});
});
// Allow +/-20% slack
expect(reconnectMs).toBeGreaterThanOrEqual(8000);
expect(reconnectMs).toBeLessThanOrEqual(12000);
});204 No ContentA server responding 204 No Content disables further reconnection.
Useful for "subscription ended" scenarios:
def stream(request):
if user_unsubscribed(request):
return Response(status=204)
# ... event stream ...Test the client gives up:
test('client stops reconnecting after server returns 204', async ({ page }) => {
// Server returns 204 immediately
const states = await page.evaluate(() => {
return new Promise<number[]>((resolve) => {
const es = new EventSource('/api/stream-204');
const seen: number[] = [];
const interval = setInterval(() => seen.push(es.readyState), 100);
setTimeout(() => {
clearInterval(interval);
resolve(seen);
}, 2000);
});
});
expect(states[states.length - 1]).toBe(2); // CLOSED
});Browsers cap concurrent HTTP/1.1 connections per origin (~6 in Chrome). SSE consumes one persistently - apps with many EventSource connections starve.
test('app uses single EventSource for fan-out', async ({ page }) => {
await page.goto('https://localhost:3000/dashboard');
const eventSourceCount = await page.evaluate(() =>
performance.getEntriesByType('resource')
.filter((r) => r.name.includes('/api/stream'))
.length
);
expect(eventSourceCount).toBe(1);
});HTTP/2 / HTTP/3 lift this limit but verify your CDN supports it end-to-end.