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; 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.
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 load testing with k6
View source (opens in new window)SSE load testing with k6
Load-tests SSE endpoints with k6, covering the HTTP/1.1 connection-ceiling problem the host SKILL.md flags but does not exercise under load. The host skill verifies correctness (format, Last-Event-ID, readyState); this reference drives concurrent virtual users against the same endpoint to find capacity limits, connection churn costs, and memory growth under sustained streams.
When to use
Overview: the HTTP/1.1 connection ceiling
Browsers cap concurrent HTTP/1.1 connections per origin at approximately 6 (Chrome, Firefox, Safari all implement this limit as a quality-of-service constraint on shared TCP stacks). The WHATWG SSE spec authoring notes (opens in new window) acknowledge this directly: "Clients that support HTTP's per-server connection limitation might run into trouble when opening multiple pages from a site if each page has an EventSource to the same domain."
Each EventSource holds one HTTP/1.1 connection open for the lifetime of the stream. A page that opens several EventSource objects, or a multi-tab scenario, will exhaust the pool and stall all other requests to the same origin. HTTP/2 removes this constraint: RFC 9113 section 1 (opens in new window) specifies that "a single HTTP/2 connection can contain multiple concurrently open streams, with either endpoint interleaving frames from multiple streams," so all EventSource connections to an HTTP/2 origin share one TCP connection.
k6 uses Go's net/http transport which respects HTTP/2 upgrade and does not enforce a browser-style per-origin limit; its VU concurrency is tunable, making it suitable for finding the server-side ceiling independently of browser caps.
Step 1 - Install k6
Follow the k6 installation guide (opens in new window) for your OS. Verify:
k6 versionThe k6/experimental/streams module used below requires k6 v0.54.0 or later (see experimental/streams release note (opens in new window)).
Step 2 - Write a k6 SSE client
k6 does not ship a native SSE protocol module. Build one using ReadableStream from k6/experimental/streams (opens in new window), which "provides a way to define and consume streams of data within your test scripts" and lets you "start processing raw data with Javascript bit by bit, as soon as it's available, without needing to generate a full in-memory representation."
// sse-load.js
import http from 'k6/http';
import { check } from 'k6';
import { ReadableStream } from 'k6/experimental/streams';
import { Counter, Trend } from 'k6/metrics';
// Custom metrics
const sseEvents = new Counter('sse_events_received');
const eventLag = new Trend('sse_event_lag_ms');
export const options = {
scenarios: {
// constant-vus holds N virtual users open for the full duration;
// each VU maps to one persistent SSE connection.
// See https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/constant-vus/
sustained_streams: {
executor: 'constant-vus',
vus: 50,
duration: '60s',
},
},
thresholds: {
// TTFB: first byte of the event stream arrives within 500 ms for 95% of VUs
// Metric reference: https://grafana.com/docs/k6/latest/using-k6/metrics/reference/
'http_req_waiting': ['p(95)<500'],
// Connection-slot starvation signal: VUs blocked waiting for a free TCP
// slot should be negligible
'http_req_blocked': ['p(99)<100'],
// Overall error rate
'http_req_failed': ['rate<0.01'],
},
};
export default async function () {
const startMs = Date.now();
// Long-lived GET; set timeout generously (k6 default is 60 s per
// https://grafana.com/docs/k6/latest/javascript-api/k6-http/params/ ).
// responseType 'text' streams body as string.
const res = http.get('http://localhost:3000/api/events', {
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
timeout: '70s',
});
check(res, {
'status 200': (r) => r.status === 200,
'correct content-type': (r) =>
(r.headers['Content-Type'] || '').includes('text/event-stream'),
});
// Parse the body that arrived before k6 closed the response.
// For a true streaming parse, replace this block with a ReadableStream
// wrapping a streaming HTTP client (see Step 3).
const lines = (res.body || '').split('\n');
let count = 0;
for (const line of lines) {
if (line.startsWith('data:')) {
count++;
eventLag.add(Date.now() - startMs);
}
}
sseEvents.add(count);
}http_req_waiting is "time spent waiting for response from remote host (a.k.a. 'time to first byte', or 'TTFB')" per the k6 metrics reference (opens in new window). It is the primary latency signal for streaming endpoints because the client must receive the first byte before any event is delivered.
Step 3 - Streaming parse with ReadableStream (optional)
For servers that keep the connection open indefinitely (infinite stream), wrap the body in a ReadableStream (opens in new window) so k6 can process events as they arrive and close the stream after N events rather than waiting for the response to complete:
import { ReadableStream } from 'k6/experimental/streams';
import { Counter } from 'k6/metrics';
const sseEvents = new Counter('sse_events_received');
const TARGET_EVENTS = 10; // drain after this many, then move on
export default async function () {
// Open the SSE connection
const res = http.get('http://localhost:3000/api/events', {
headers: { Accept: 'text/event-stream' },
timeout: '120s',
responseType: 'none', // discard buffered body; we read from the stream
});
let buffer = '';
let received = 0;
const stream = new ReadableStream({
async pull(controller) {
// In a real integration, wire this to the chunked HTTP response.
// k6's http module does not expose a streaming body reader natively;
// for production use, combine with an xk6 extension or poll a shared
// channel between the VU and a background goroutine.
if (received >= TARGET_EVENTS) {
controller.close();
return;
}
// Simulate processing: parse lines from accumulated buffer
// Replace this with actual chunk reads from your transport layer.
controller.enqueue(buffer);
},
});
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = (value || '').split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
received++;
sseEvents.add(1);
}
}
}
}Note: ReadableStream is experimental and "may introduce breaking changes in future releases" per the k6 streams docs (opens in new window). Pin your k6 version in CI.
Step 4 - Measure concurrent-stream capacity
Ramp VUs upward in stages to find the inflection point where http_req_blocked climbs (TCP slot exhaustion on the server's accept queue) or data_received per VU drops (back-pressure):
export const options = {
scenarios: {
ramp_streams: {
executor: 'ramping-vus',
startVUs: 10,
stages: [
{ duration: '30s', target: 100 },
{ duration: '60s', target: 500 },
{ duration: '60s', target: 1000 },
{ duration: '30s', target: 0 },
],
},
},
thresholds: {
'http_req_waiting': ['p(95)<500'],
'http_req_blocked': ['p(99)<100'],
'http_req_failed': ['rate<0.02'],
'data_received': ['count>0'],
},
};vus (current active VUs) and vus_max (peak concurrent VUs) appear in the k6 summary and Grafana dashboard automatically; no custom metric needed (k6 metrics reference (opens in new window)).
Step 5 - Measure connection churn
Connection churn (clients that connect, receive a few events, then disconnect and reconnect) stresses the server's connection-setup path more than a stable pool. Model churn with short-lived iterations and a reconnect loop:
export const options = {
scenarios: {
churn: {
executor: 'constant-arrival-rate',
rate: 20, // 20 new SSE connections per second
timeUnit: '1s',
duration: '60s',
preAllocatedVUs: 40,
},
},
};
export default function () {
// Each iteration: connect, receive 3 events, disconnect.
const res = http.get('http://localhost:3000/api/events', {
headers: { Accept: 'text/event-stream' },
timeout: '10s',
});
check(res, { 'status 200': (r) => r.status === 200 });
// Rapid disconnect after partial read - stresses server close path
}http_req_connecting ("time spent establishing TCP connection to the remote host") will reveal whether TLS+TCP handshake cost dominates at high churn rates (k6 metrics reference (opens in new window)).
Step 6 - Run and interpret results
k6 run sse-load.jsKey output fields:
| Metric | What it tells you |
|---|---|
http_req_waiting p(95) | TTFB for the first event; high values mean server event-loop saturation |
http_req_blocked p(99) | Time waiting for a free TCP slot; spikes mean connection exhaustion |
http_req_connecting avg | Per-connection handshake cost; high under churn means TLS overhead |
data_received total | Aggregate byte throughput; divide by duration and VU count for per-stream rate |
sse_events_received | Custom counter; divide by vus to verify events are flowing to all streams |
http_req_failed rate | Unexpected closes or non-200 responses under load |
A passing run shows http_req_blocked near 0 (no TCP slot contention), http_req_waiting within TTFB threshold, and sse_events_received growing linearly with VU count.
Step 7 - HTTP/1.1 vs HTTP/2 comparison
Run the same scenario against the HTTP/1.1 and HTTP/2 endpoints. On HTTP/1.1, http_req_blocked will rise as concurrent VUs approach the server's accept queue depth. On HTTP/2, the single multiplexed TCP connection (per RFC 9113 (opens in new window)) means http_req_blocked stays near 0 and http_req_connecting drops sharply because new streams reuse the existing connection rather than performing a fresh TCP+TLS handshake.
# HTTP/1.1 target
k6 run -e TARGET=http://localhost:3000/api/events sse-load.js
# HTTP/2 target (same VU count)
k6 run -e TARGET=https://localhost:3000/api/events sse-load.jsCompare the http_req_connecting and http_req_blocked summaries.
Example output (passing run, 50 VUs, HTTP/2)
scenarios: (100.00%) 1 scenario, 50 max VUs, 1m30s max duration
default: 50 looping VUs for 1m0s (gracefulStop: 30s)
http_req_blocked............: avg=1.2ms p(99)=8ms
http_req_connecting.........: avg=3.1ms p(95)=12ms
http_req_waiting............: avg=42ms p(95)=180ms
http_req_failed.............: 0.00%
data_received...............: 14 MB 230 kB/s
sse_events_received.........: 4800
thresholds:
http_req_waiting p(95)<500 - OK
http_req_blocked p(99)<100 - OK
http_req_failed rate<0.01 - OKAnti-patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
| Single short-duration iteration per VU | Does not model a persistent connection; misses steady-state memory growth | Use constant-vus with a multi-minute duration |
| No TTFB threshold | First-byte latency regression goes undetected | Gate http_req_waiting p(95) |
Ignoring http_req_blocked | TCP slot exhaustion masked by passing error rate | Add http_req_blocked p(99) threshold |
| Testing HTTP/1.1 only | Misses multiplexing benefit; may over-provision TCP connections | Run the scenario against both HTTP/1.1 and HTTP/2 (Step 7) |
| Hard-coding VU count without a ramp | Misses the capacity cliff; first overload is discovered in production | Use ramping-vus to find the inflection point (Step 4) |
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
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. Also carries STOMP-over-WebSocket + AMQP 0-9-1 broker testing (Spring / RabbitMQ frame, ack-mode, and exchange/binding tests via Testcontainers) in references/stomp-amqp.md. Use when a product speaks MQTT, STOMP, or AMQP on the wire and QoS 1 / 2 redelivery, retained-message state, LWT, ack-mode, or broker-topology behavior needs a broker-backed test - including smoke-testing a new broker auth / ACL / persistence config.
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.