Server-Sent Events Testing Strategies
Server-Sent Events (SSE) are the simplest way to push data from server to client: a persistent HTTP connection over which the server streams text events. No WebSocket handshake, no binary framing, no bidirectional complexity — just a long-lived HTTP response with a specific content type.
That simplicity makes SSE easy to implement but surprisingly tricky to test. The streaming nature, the automatic reconnection built into the EventSource API, and the stateful connection mean that ordinary HTTP testing tools fall short.
What SSE Testing Covers
An SSE endpoint has several distinct behaviors to test:
- Connection setup: correct headers, proper
text/event-streamcontent type - Event format: field names, data encoding, event ID, retry field
- Event streaming: events arrive in order, with correct timing
- Connection persistence: server keeps connection open; no premature close
- Reconnection: client reconnects after disconnect;
Last-Event-IDheader sent - Retry field: server-specified retry interval is respected
- Multiple event types:
event:field correctly routes to listeners - Connection cleanup: server handles client disconnect gracefully
Testing SSE on the Server Side
The cleanest approach tests your SSE endpoint directly via HTTP, without relying on the browser's EventSource API.
Node.js SSE Testing
const http = require('http');
const app = require('./app'); // Your Express/Fastify app
describe('SSE endpoint', () => {
let server;
beforeAll((done) => {
server = app.listen(0, done);
});
afterAll((done) => server.close(done));
function connectSSE(path, headers = {}) {
return new Promise((resolve, reject) => {
const port = server.address().port;
const req = http.get(`http://localhost:${port}${path}`, {
headers: {
'Accept': 'text/event-stream',
...headers,
},
}, (res) => {
resolve(res);
});
req.on('error', reject);
});
}
test('returns correct SSE headers', async () => {
const res = await connectSSE('/events');
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('text/event-stream');
expect(res.headers['cache-control']).toBe('no-cache');
expect(res.headers['connection']).toBe('keep-alive');
res.destroy(); // Close the connection
});
test('sends initial connection event', async () => {
const res = await connectSSE('/events');
const firstEvent = await readNextEvent(res);
expect(firstEvent.event).toBe('connected');
expect(firstEvent.data).toContain('id');
res.destroy();
});
});Parsing SSE Format
SSE has a specific text format. Build a parser for your tests:
function parseSSEEvent(raw) {
const lines = raw.trim().split('\n');
const event = {};
for (const line of lines) {
if (line.startsWith('data:')) {
event.data = line.slice(5).trim();
} else if (line.startsWith('event:')) {
event.event = line.slice(6).trim();
} else if (line.startsWith('id:')) {
event.id = line.slice(3).trim();
} else if (line.startsWith('retry:')) {
event.retry = parseInt(line.slice(6).trim(), 10);
}
}
return event;
}
function readNextEvent(response, timeout = 5000) {
return new Promise((resolve, reject) => {
let buffer = '';
const timer = setTimeout(() => reject(new Error('SSE event timeout')), timeout);
response.on('data', (chunk) => {
buffer += chunk.toString();
// SSE events are separated by double newlines
const eventEnd = buffer.indexOf('\n\n');
if (eventEnd !== -1) {
const eventRaw = buffer.slice(0, eventEnd);
buffer = buffer.slice(eventEnd + 2);
clearTimeout(timer);
resolve(parseSSEEvent(eventRaw));
}
});
response.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
function readNEvents(response, n, timeout = 10000) {
return new Promise((resolve, reject) => {
const events = [];
let buffer = '';
const timer = setTimeout(
() => reject(new Error(`Timeout: got ${events.length}/${n} events`)),
timeout
);
response.on('data', (chunk) => {
buffer += chunk.toString();
let eventEnd;
while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
const eventRaw = buffer.slice(0, eventEnd);
buffer = buffer.slice(eventEnd + 2);
if (eventRaw.trim()) {
events.push(parseSSEEvent(eventRaw));
}
if (events.length >= n) {
clearTimeout(timer);
resolve(events);
return;
}
}
});
response.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}Testing Event Stream Content
describe('SSE event content', () => {
test('price updates contain required fields', async () => {
const res = await connectSSE('/prices/BTC');
const events = await readNEvents(res, 3);
for (const event of events) {
expect(event.event).toBe('price');
const data = JSON.parse(event.data);
expect(data).toMatchObject({
symbol: 'BTC',
price: expect.any(Number),
timestamp: expect.any(Number),
});
}
res.destroy();
});
test('events have incrementing IDs', async () => {
const res = await connectSSE('/events');
const events = await readNEvents(res, 5);
for (let i = 1; i < events.length; i++) {
expect(Number(events[i].id)).toBeGreaterThan(Number(events[i-1].id));
}
res.destroy();
});
test('heartbeat events sent every 15 seconds', async () => {
const res = await connectSSE('/events');
const timestamps = [];
for (let i = 0; i < 3; i++) {
const event = await readNextEvent(res, 20000);
if (event.event === 'heartbeat') {
timestamps.push(Date.now());
}
}
// Check interval between heartbeats
for (let i = 1; i < timestamps.length; i++) {
const interval = timestamps[i] - timestamps[i-1];
expect(interval).toBeGreaterThanOrEqual(14000);
expect(interval).toBeLessThanOrEqual(16000);
}
res.destroy();
});
test('multiple event types are correctly labeled', async () => {
const res = await connectSSE('/feed');
const events = await readNEvents(res, 10);
const eventTypes = new Set(events.map(e => e.event));
expect(eventTypes).toContain('update');
expect(eventTypes).toContain('heartbeat');
// Not a single unlabeled event
events.forEach(e => expect(e.event).toBeDefined());
res.destroy();
});
});Reconnection Testing
The EventSource API automatically reconnects after disconnection, sending the Last-Event-ID header. Your server should use this to replay missed events.
describe('SSE reconnection', () => {
test('server sends retry field to set reconnect interval', async () => {
const res = await connectSSE('/events');
const firstEvent = await readNextEvent(res);
expect(firstEvent.retry).toBeDefined();
expect(firstEvent.retry).toBeGreaterThan(0);
res.destroy();
});
test('server replays missed events on reconnection', async () => {
// Connect and receive first 3 events
const res1 = await connectSSE('/events');
const firstBatch = await readNEvents(res1, 3);
const lastId = firstBatch[firstBatch.length - 1].id;
res1.destroy();
await sleep(500);
// Reconnect with Last-Event-ID
const res2 = await connectSSE('/events', { 'Last-Event-ID': lastId });
const secondBatch = await readNEvents(res2, 3);
// Second batch IDs should be greater than lastId
for (const event of secondBatch) {
expect(Number(event.id)).toBeGreaterThan(Number(lastId));
}
res2.destroy();
});
test('server handles duplicate Last-Event-ID gracefully', async () => {
// Reconnect with same ID twice - should not crash server
const res1 = await connectSSE('/events', { 'Last-Event-ID': '999999' });
const event = await readNextEvent(res1);
expect(event).toBeDefined();
res1.destroy();
});
});Testing Server Cleanup on Client Disconnect
When a client disconnects, your server should clean up: remove the client from any active subscriptions, stop generating events for that connection, and free associated memory.
describe('server cleanup', () => {
test('active connection count decreases after disconnect', async () => {
const before = await getActiveConnections(); // Your metrics endpoint
const res = await connectSSE('/events');
await sleep(100);
const during = await getActiveConnections();
expect(during).toBe(before + 1);
res.destroy();
await sleep(200); // Give server time to detect disconnect
const after = await getActiveConnections();
expect(after).toBe(before);
});
test('server stops emitting after client disconnect', async () => {
// Track server-side event generation
let eventsGenerated = 0;
app.on('sse:event-sent', () => eventsGenerated++);
const res = await connectSSE('/events');
await sleep(500);
const countBeforeDisconnect = eventsGenerated;
res.destroy();
await sleep(500);
const countAfterDisconnect = eventsGenerated;
// Should generate at most 1-2 more events (in-flight), then stop
expect(countAfterDisconnect - countBeforeDisconnect).toBeLessThan(3);
});
});Authorization Testing
describe('SSE authorization', () => {
test('unauthenticated request returns 401', async () => {
const res = await connectSSE('/private/events');
expect(res.statusCode).toBe(401);
});
test('valid Bearer token in header allows connection', async () => {
const token = await generateValidToken();
const res = await connectSSE('/private/events', {
'Authorization': `Bearer ${token}`,
});
expect(res.statusCode).toBe(200);
res.destroy();
});
test('expired token closes stream with error event', async () => {
const expiredToken = await generateExpiredToken();
const res = await connectSSE('/private/events', {
'Authorization': `Bearer ${expiredToken}`,
});
if (res.statusCode === 200) {
// Server accepted connection, should send error event then close
const event = await readNextEvent(res);
expect(event.event).toBe('error');
expect(JSON.parse(event.data).code).toBe('TOKEN_EXPIRED');
} else {
expect(res.statusCode).toBe(401);
}
});
});Testing from the Browser Perspective
For end-to-end tests with a real EventSource in the browser:
// Playwright/Puppeteer test
test('SSE updates UI in real time', async ({ page }) => {
await page.goto('http://localhost:3000/dashboard');
// Capture SSE events via page evaluation
await page.evaluate(() => {
window._sseEvents = [];
const es = new EventSource('/api/events');
es.addEventListener('update', (e) => {
window._sseEvents.push(JSON.parse(e.data));
});
});
// Trigger a server-side event
await fetch('http://localhost:3000/api/trigger-update', { method: 'POST' });
// Wait for UI to reflect the update
await page.waitForFunction(() => window._sseEvents.length > 0, { timeout: 5000 });
const events = await page.evaluate(() => window._sseEvents);
expect(events[0].type).toBe('update');
// Verify DOM updated
await expect(page.locator('[data-testid="live-count"]')).not.toHaveText('0');
});Monitoring SSE with HelpMeTest
SSE streams need continuous monitoring — not just unit tests. A broken SSE endpoint often stays up (returns 200) but stops sending events, and unit tests won't catch a production degradation.
*** Test Cases ***
SSE Stream Health Check
[Documentation] Verify SSE endpoint delivers events continuously
${response}= Open SSE Connection https://api.yourapp.com/events
... headers=Authorization: Bearer ${TOKEN}
Connection Status Code Should Be ${response} 200
Content Type Should Be ${response} text/event-stream
${event}= Read Next SSE Event ${response} timeout=10s
Event Should Have Field ${event} event
Event Data Should Be Valid JSON ${event}
Close SSE Connection ${response}Run this health check every 60 seconds. SSE connections can silently stop delivering events due to proxy timeouts, server-side queue starvation, or connection count limits — none of which show up in HTTP 200 responses.
Common SSE Bugs to Test For
Missing headers: A response without Cache-Control: no-cache will be buffered by some proxies, making events appear delayed or batched. Test that all required headers are present.
Event format violations: Events must end with \n\n (double newline). A single \n won't flush the event to the client in most implementations. Test your output format.
Proxy buffering: Nginx and other reverse proxies buffer responses by default. Your SSE endpoint needs X-Accel-Buffering: no (for Nginx) to disable buffering. Test through your actual proxy stack.
Connection limits: Browsers limit SSE connections to the same origin (historically 6 per domain for HTTP/1.1). Test behavior when connection limits are hit.
Memory leaks on the server: If your server keeps a reference to each SSE client and doesn't clean up on disconnect, memory grows without bound. Load test with repeated connect/disconnect cycles and monitor server memory.