Testing Server-Sent Events: SSE, EventSource, and Reconnection
Server-Sent Events get overlooked. Teams reach for WebSockets by default, then discover they've built a complex bidirectional system for a problem that only required one-way server push. SSE is simpler, uses plain HTTP, works through proxies without configuration, and reconnects automatically. For a large class of real-time features — notifications, live dashboards, feed updates — SSE is the better tool.
But SSE testing is even less documented than WebSocket testing. This guide covers the full picture: understanding the protocol, testing with curl, writing SSE tests in JavaScript, and load testing.
SSE vs WebSocket: When to Use Each
Before testing SSE, understand when you should be using it versus WebSocket.
Use SSE when:
- Communication is one-directional: server pushes to client
- You need automatic reconnection without client-side logic
- You want to work through existing HTTP infrastructure without firewall changes
- Your clients are browsers (EventSource is native, no library needed)
- You're building: live feeds, notifications, progress updates, streaming AI responses
Use WebSocket when:
- Clients need to send data frequently (chat, gaming, collaborative editing)
- You need low-latency bidirectional communication
- You're dealing with non-browser clients that need binary data
The common mistake: using WebSockets for a notification system where the client only receives updates. SSE handles this with less code and fewer operational headaches.
The Protocol
SSE is plain HTTP. The server sends a response with Content-Type: text/event-stream and keeps the connection open, streaming data in a specific text format:
id: 1
event: price-update
data: {"symbol":"AAPL","price":150.23}
id: 2
event: price-update
data: {"symbol":"MSFT","price":320.10}
Each event is a block of key-value lines followed by a blank line. Fields:
data:— The payload (required). Multipledata:lines are concatenated with newlines.event:— Event type (optional, defaults to "message")id:— Event ID used for reconnection (optional but important)retry:— Reconnection delay in milliseconds (optional)
The browser's EventSource automatically reconnects and sends the Last-Event-ID header with the last received ID, allowing the server to resume from where it left off.
Testing with curl
curl is your first debugging tool. A plain GET to an SSE endpoint should stream events:
# Basic SSE connection
curl -N -H "Accept: text/event-stream" https://api.example.com/events
# With auth header
curl -N -H "Accept: text/event-stream" \
-H "Authorization: Bearer your-token" \
https://api.example.com/events
# Check the response headers (separate from body)
curl -N -I -H "Accept: text/event-stream" https://api.example.com/events
# Verify reconnection header is sent
curl -N -H "Accept: text/event-stream" \
-H "Last-Event-ID: 42" \
https://api.example.com/eventsThe -N flag disables output buffering, so you see events as they arrive rather than waiting for the connection to close (which it never will for a healthy SSE endpoint).
What to check manually:
Content-Type: text/event-streamheader is presentCache-Control: no-cacheis set (prevents proxy caching)Connection: keep-aliveis set- Events flow in the expected format
- The server correctly handles
Last-Event-IDfor resumption
Writing SSE Tests in JavaScript
For Node.js, the eventsource package provides an EventSource implementation:
npm install eventsourceBasic test structure:
const EventSource = require('eventsource');
describe('SSE endpoint', () => {
test('streams events after connection', (done) => {
const es = new EventSource('http://localhost:3000/events', {
headers: { Authorization: 'Bearer test-token' },
});
const received = [];
es.addEventListener('price-update', (event) => {
received.push(JSON.parse(event.data));
if (received.length >= 3) {
es.close();
expect(received[0].symbol).toBeDefined();
expect(received[0].price).toBeGreaterThan(0);
done();
}
});
es.onerror = (err) => {
es.close();
done(new Error(`SSE error: ${JSON.stringify(err)}`));
};
}, 15000);
});For async/await patterns, wrap EventSource in a Promise:
function collectSSEEvents(url, eventType, count, options = {}) {
return new Promise((resolve, reject) => {
const es = new EventSource(url, options);
const events = [];
const timeout = setTimeout(() => {
es.close();
reject(new Error(`Timeout: collected ${events.length}/${count} events`));
}, options.timeout || 10000);
es.addEventListener(eventType, (event) => {
events.push(JSON.parse(event.data));
if (events.length >= count) {
clearTimeout(timeout);
es.close();
resolve(events);
}
});
es.onerror = (err) => {
clearTimeout(timeout);
es.close();
reject(err);
};
});
}
// Usage
test('receives 5 price updates', async () => {
const events = await collectSSEEvents(
'http://localhost:3000/events',
'price-update',
5,
{ headers: { Authorization: 'Bearer test-token' } }
);
expect(events).toHaveLength(5);
events.forEach(event => {
expect(event.symbol).toMatch(/^[A-Z]+$/);
expect(event.price).toBeGreaterThan(0);
expect(event.timestamp).toBeDefined();
});
});Testing Event Types and Data Formats
If your SSE endpoint emits multiple event types, test each one explicitly:
test('emits correct event types', async () => {
const es = new EventSource('http://localhost:3000/events', {
headers: { Authorization: 'Bearer test-token' },
});
const eventTypes = new Set();
await new Promise((resolve) => {
es.onmessage = (event) => {
eventTypes.add(event.type);
if (eventTypes.size >= 3) {
es.close();
resolve();
}
};
// Listen for all named event types
['price-update', 'trade', 'orderbook-update'].forEach(type => {
es.addEventListener(type, (event) => {
eventTypes.add(type);
if (eventTypes.size >= 3) {
es.close();
resolve();
}
});
});
});
expect(eventTypes.has('price-update')).toBe(true);
});Test that event IDs are present and sequential (or at least monotonically increasing):
test('events have sequential IDs', async () => {
const events = await collectSSEEventsWithMetadata(
'http://localhost:3000/events',
10
);
const ids = events.map(e => parseInt(e.lastEventId));
// Each ID should be greater than the previous
for (let i = 1; i < ids.length; i++) {
expect(ids[i]).toBeGreaterThan(ids[i - 1]);
}
});Testing Reconnection Behavior
Reconnection is SSE's killer feature. The browser EventSource handles it automatically. Test that your server supports it correctly:
test('resumes from last event ID after reconnect', async () => {
// First connection — collect 5 events and note the last ID
let lastEventId;
const firstBatch = await new Promise((resolve, reject) => {
const es = new EventSource('http://localhost:3000/events', {
headers: { Authorization: 'Bearer test-token' },
});
const events = [];
es.onmessage = (event) => {
events.push(event);
lastEventId = event.lastEventId;
if (events.length >= 5) {
es.close();
resolve(events);
}
};
});
// Second connection — provide Last-Event-ID
const secondBatch = await collectSSEEvents(
'http://localhost:3000/events',
'message',
5,
{
headers: {
Authorization: 'Bearer test-token',
'Last-Event-ID': lastEventId,
},
}
);
// Second batch should start after the last event from first batch
const firstBatchLastId = parseInt(lastEventId);
const secondBatchFirstId = parseInt(secondBatch[0].id);
expect(secondBatchFirstId).toBeGreaterThan(firstBatchLastId);
});Test that the server handles Last-Event-ID: 0 (first connection) and missing Last-Event-ID (also first connection) identically:
test('handles missing Last-Event-ID gracefully', async () => {
const events = await collectSSEEvents(
'http://localhost:3000/events',
'message',
3
);
expect(events).toHaveLength(3);
});Auth Testing
SSE auth has the same challenge as WebSocket auth: the browser's EventSource doesn't support custom headers. You have a few options:
Token in query string (common, not ideal):
GET /events?token=abc123Test that missing or invalid tokens get rejected:
test('rejects connection without token', async () => {
await expect(
collectSSEEvents('http://localhost:3000/events', 'message', 1, {
timeout: 3000,
})
).rejects.toThrow();
});
test('rejects connection with invalid token', async () => {
await expect(
collectSSEEvents('http://localhost:3000/events?token=invalid', 'message', 1, {
timeout: 3000,
})
).rejects.toThrow();
});Cookie-based auth (works natively with EventSource): The browser includes cookies automatically. In tests, set the cookie on the EventSource options:
const es = new EventSource('/events', {
withCredentials: true, // Include cookies
});For Node.js test clients, pass the cookie as a header:
const es = new EventSource('http://localhost:3000/events', {
headers: { Cookie: 'session=your-session-token' },
});Custom header auth (requires a proxy or non-browser EventSource): In tests, use the eventsource package's header support. In production browsers, you'll need to proxy through a service worker or use a polling fallback.
Load Testing SSE
SSE is simpler to load test than WebSockets — it's just HTTP. k6 handles SSE well:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 200,
duration: '2m',
thresholds: {
http_req_duration: ['p95<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const response = http.get('https://api.example.com/events', {
headers: {
Accept: 'text/event-stream',
Authorization: 'Bearer test-token',
},
timeout: '30s',
});
check(response, {
'status 200': (r) => r.status === 200,
'content type is event stream': (r) =>
r.headers['Content-Type'].includes('text/event-stream'),
'received data': (r) => r.body.length > 0,
});
}For a more realistic SSE load test that maintains persistent connections, you'll need a lower-level approach or a dedicated tool. The key metrics to track for SSE load:
- Time to first event — How long after connecting does the first event arrive?
- Event throughput — Events per second across all connections
- Connection keep-alive duration — Are connections being dropped prematurely?
- Server memory per connection — SSE connections are cheaper than WS but still consume resources
Common SSE Bugs to Test For
Missing Content-Type header. The browser EventSource silently fails if the Content-Type isn't text/event-stream. Always test that the header is present and correct.
Buffering proxies. Nginx and other proxies buffer responses by default. An SSE endpoint behind a misconfigured proxy appears to work (HTTP 200, correct headers) but events never arrive — they're buffered until the connection closes. Test by checking that events arrive within a few seconds of being sent, not in a delayed batch.
No event IDs. Without IDs, reconnection sends no Last-Event-ID, and the server can't resume. All events since the disconnect are lost. Test reconnection explicitly to catch this.
Heartbeat absence. Many proxies close idle connections after 30-60 seconds. If your server doesn't send periodic heartbeats, connections silently drop. Test connection survival over several minutes:
test('connection stays alive for 3 minutes', async () => {
const es = new EventSource('http://localhost:3000/events', {
headers: { Authorization: 'Bearer test-token' },
});
let connectionAlive = true;
es.onerror = () => { connectionAlive = false; };
await new Promise(resolve => setTimeout(resolve, 180000));
expect(connectionAlive).toBe(true);
es.close();
}, 200000); // Extended timeoutSSE is underused in part because engineers assume "we'll need bidirectional later so let's just use WebSockets." Often they don't need bidirectional. If your use case is server push — notifications, live data, streaming responses — SSE is simpler to build, simpler to test, and simpler to operate. Test it properly and it'll serve you well.
For teams building real-time features and looking for an end-to-end testing layer beyond unit tests — HelpMeTest supports testing SSE and WebSocket behavior as part of automated browser-based test flows, so you can catch integration failures before they reach users.