server-sent-events-tests
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. Use when a feature pushes updates over `text/event-stream` and the reconnect interval, `Last-Event-ID` replay, or per-origin connection ceiling has no coverage.
Install with skills.sh (any agent)
npx skills add testland/qa --skill server-sent-events-testsserver-sent-events-tests
Tests the SSE surfaces per the WHATWG SSE spec (opens in new window): stream format, readyState lifecycle, Last-Event-ID reconnect-with-replay, and the HTTP/1.1 connection-pool ceiling.
When to use
Step 1 - Server-side event stream format
Per the WHATWG SSE spec (opens in new window), response must use Content-Type: text/event-stream (UTF-8) and stream lines:
| Field | Meaning |
|---|---|
data: | Appends to message payload (multiple data: lines join with newlines) |
event: | Custom event type (default = message) |
id: | Sets last event ID for reconnect replay |
retry: | Reconnect interval (ms) |
: | Comment line (kept-alive heartbeat) |
Empty line ends a message. Example:
event: order_update
id: 142
data: {"orderId":"o123","status":"shipped"}
event: order_update
id: 143
data: {"orderId":"o124","status":"shipped"}
Step 2 - Browser test (Playwright)
import { test, expect } from '@playwright/test';
test('client receives server-pushed events', async ({ page }) => {
await page.goto('https://localhost:3000/dashboard');
const events = await page.evaluate(() => {
return new Promise<any[]>((resolve) => {
const collected: any[] = [];
const es = new EventSource('/api/orders/stream');
es.addEventListener('order_update', (e: any) => {
collected.push(JSON.parse(e.data));
if (collected.length === 2) {
es.close();
resolve(collected);
}
});
});
});
expect(events).toHaveLength(2);
expect(events[0].orderId).toBe('o123');
});Step 3 - readyState lifecycle
Per the WHATWG SSE spec (opens in new window), readyState values:
| Value | State |
|---|---|
| 0 | CONNECTING |
| 1 | OPEN |
| 2 | CLOSED |
test('readyState transitions through CONNECTING → OPEN', async ({ page }) => {
await page.goto('https://localhost:3000/dashboard');
const transitions = await page.evaluate(() => {
return new Promise<number[]>((resolve) => {
const states: number[] = [];
const es = new EventSource('/api/stream');
states.push(es.readyState); // 0
es.onopen = () => {
states.push(es.readyState); // 1
es.close();
states.push(es.readyState); // 2
resolve(states);
};
});
});
expect(transitions).toEqual([0, 1, 2]);
});Deeper recipes
Reconnect-with-replay via Last-Event-ID, the retry: interval, 204 No Content disable, and the HTTP/1.1 connection-pool ceiling are in references/sse-test-recipes.md.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Wrong content-type | Browser doesn't recognize as SSE | Content-Type: text/event-stream (Step 1) |
| Skip newline-newline message terminator | Browser buffers indefinitely | Always end messages with \n\n (Step 1) |
No id: on events | Last-Event-ID replay impossible | Always emit id: (see references) |
Multiple EventSource per page on HTTP/1.1 | Connection pool starvation | One stream + multiplex via event: (see references) |
| Use SSE for two-way comms | One-way only; need WebSocket for client→server | Use websocket-tests skill instead |
Limitations
References
SSE test recipes
View source (opens in new window)SSE test recipes
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 (opens in new window).
Reconnect-with-replay via Last-Event-ID
On 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.
Reconnect interval (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);
});Disable reconnect via 204 No Content
A 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
});HTTP/1.1 connection-pool ceiling
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.
Related skills
grpc-streaming-tests
Test gRPC streaming RPCs - Server-streaming (server returns sequence), Client-streaming (client sends sequence), Bidirectional (both sides stream independently). Cover deadline + cancellation + flow control + status codes (CANCELLED, DEADLINE_EXCEEDED) + metadata. Use ghz for load, grpcurl for ad-hoc, language-native test stubs for unit/integration. Use when a service exposes server-, client-, or bidirectional-streaming RPCs and deadline, cancellation, or partial-stream status-code behavior is unverified.
mqtt-tests
Test MQTT v5.0 with Mosquitto broker in CI + paho-mqtt clients - QoS 0 / 1 / 2 delivery semantics, retained messages, Last Will and Testament (LWT), shared subscriptions ($share/group/topic), $SYS topic introspection. Critical for IoT, embedded, and M2M systems where wire-level guarantees matter. Use when a product speaks MQTT on the wire and QoS 1 / 2 redelivery, retained-message state, or LWT behavior needs a broker-backed test - including smoke-testing a new broker auth / ACL / persistence config.
sse-load-tests
Load-tests SSE endpoints at scale with k6 - measures concurrent-stream capacity, connection churn, and server memory pressure. Covers the HTTP/1.1 6-connection-per-origin browser ceiling vs HTTP/2 multiplexing, a custom k6 SSE client built on ReadableStream, and threshold gates for TTFB and data throughput. Use when validating whether a server can sustain N concurrent EventSource connections without connection starvation or memory growth.
stomp-amqp-tests
Tests STOMP over WebSocket (Spring, ActiveMQ, RabbitMQ Web STOMP) and AMQP 0-9-1 (RabbitMQ Java client) - frame connect/subscribe/send/ack sequences, ack modes (auto/client/client-individual), exchange and queue declarations, binding routing, Testcontainers RabbitMQ broker, and delivery assertion. Use when validating enterprise Spring or RabbitMQ messaging stacks before deploy.
webhook-replay-tests
Tests inbound webhook receivers for replay-attack resistance: capture incoming webhook payloads + headers, replay against the receiver under test, validate the Standard Webhooks signature scheme (svix-id + svix-timestamp + svix-signature, HMAC-SHA256 over `{id}.{timestamp}.{payload}`), svix-id idempotency dedup, and 5-minute timestamp-window enforcement by signing fixtures at runtime. Does NOT cover outbound delivery, retry-on-5xx, or failure-event exhaustion - those belong to an outbound webhook delivery harness. Use when testing the receiving side of a webhook integration.
websocket-tests
Test WebSocket protocol behavior - opening handshake (HTTP Upgrade with Sec-WebSocket-Key + Sec-WebSocket-Version: 13), control frames (ping 0x9 / pong 0xA / close 0x8), close-frame status codes (1000 normal, 1001 going-away, 1006 abnormal, 1011 server error), subprotocol negotiation, backpressure, and reconnect with jitter. Works with ws (Node), websockets (Python), or Playwright frame inspection per language. Use when a feature holds a long-lived WebSocket open and reconnect, close-code, or backpressure behavior is unverified.