Server-Sent Events: The Complete Guide to SSE, EventSource & Real-Time Streaming
Server-Sent Events (SSE) is the most underrated real-time technology on the web. While WebSockets get all the attention, SSE provides a simpler, more reliable, and HTTP-native way to push data from server to client. If your use case is unidirectional — notifications, live feeds, progress updates — SSE is almost certainly the better choice.
SSE vs WebSocket vs Polling#
Before choosing a transport, understand the trade-offs:
| Feature | Polling | SSE | WebSocket |
|---|---|---|---|
| Direction | Client-to-server | Server-to-client | Bidirectional |
| Protocol | HTTP | HTTP | WS (upgrade from HTTP) |
| Auto-reconnect | Manual | Built-in | Manual |
| Binary data | No | No (text only) | Yes |
| Browser support | Universal | All modern browsers | All modern browsers |
| HTTP/2 multiplexing | N/A | Yes | No |
| Load balancer friendly | Yes | Yes | Requires sticky sessions |
| Max connections | N/A | 6 per domain (HTTP/1.1) | No hard limit |
Use SSE when you need server-to-client push over HTTP with automatic reconnection. Use WebSocket when you need bidirectional communication or binary data. Use polling when you need simplicity and updates are infrequent.
The EventSource API#
The browser-native EventSource API makes consuming SSE trivial:
const source = new EventSource('/api/events');
source.onopen = () => {
console.log('Connection opened');
};
source.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
source.onerror = (error) => {
console.error('SSE error:', error);
};
The connection is established with a standard HTTP GET request. The server responds with Content-Type: text/event-stream and keeps the connection open.
SSE Wire Format#
The SSE protocol is plain text. Each message consists of fields separated by newlines:
data: {"userId": 42, "action": "login"}
event: notification
data: {"message": "New comment on your post"}
id: msg-1001
: this is a comment (ignored by client)
retry: 5000
Key fields:
data:— The message payload. Multipledata:lines are concatenated with newlines.event:— Custom event type. Without it, themessageevent fires.id:— Sets the last event ID. Sent back on reconnection viaLast-Event-IDheader.retry:— Tells the client how many milliseconds to wait before reconnecting.
Server-Side Implementation#
A minimal Node.js SSE endpoint:
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const intervalId = setInterval(() => {
const data = JSON.stringify({ time: Date.now() });
res.write(`data: ${data}\n\n`);
}, 1000);
req.on('close', () => {
clearInterval(intervalId);
res.end();
});
});
Critical details: each message ends with two newlines (\n\n). A single newline separates fields within a message. Forgetting this is the most common SSE bug.
Reconnection and Last-Event-ID#
SSE has built-in reconnection. When the connection drops, the browser automatically reconnects after a delay (default ~3 seconds, configurable via retry:).
On reconnection, the browser sends the Last-Event-ID header with the last received id: value. Your server should use this to resume the stream:
app.get('/api/events', (req, res) => {
const lastId = req.headers['last-event-id'];
const events = getEventsSince(lastId); // fetch missed events
// Send missed events first
for (const event of events) {
res.write(`id: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`);
}
// Then continue streaming new events
subscribeToNewEvents(res);
});
This gives you exactly-once delivery semantics if you assign monotonically increasing IDs and persist events briefly.
Custom Event Types#
Named events let you multiplex different message types over a single connection:
event: notification
data: {"type": "mention", "from": "alice"}
event: presence
data: {"userId": 42, "status": "online"}
On the client, listen for specific event types:
source.addEventListener('notification', (e) => {
showNotification(JSON.parse(e.data));
});
source.addEventListener('presence', (e) => {
updatePresence(JSON.parse(e.data));
});
This is cleaner than a single onmessage handler with a type-switching if/else block.
Authentication with SSE#
The EventSource API does not support custom headers. This is its biggest limitation. Workarounds:
1. Cookie-based auth (simplest)
If your auth uses HTTP-only cookies, SSE works out of the box — the browser sends cookies with the EventSource request.
2. URL token
Pass the token as a query parameter:
const source = new EventSource(`/api/events?token=${accessToken}`);
This leaks the token in server logs and browser history. Use short-lived tokens.
3. Polyfill with fetch
Use a library like eventsource-parser with fetch to get full header control:
const response = await fetch('/api/events', {
headers: { Authorization: `Bearer ${token}` },
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Parse SSE frames manually from the stream
This sacrifices automatic reconnection but gives you full control.
Scaling SSE with Redis Pub/Sub#
A single server can handle thousands of SSE connections. But when you scale horizontally, each server only knows about its own connected clients. Redis pub/sub bridges this gap:
┌──────────┐ publish ┌───────────┐ subscribe ┌──────────┐
│ Service A │ ──────────────▶ │ Redis │ ◀──────────────── │ SSE Srv 1│
└──────────┘ │ Pub/Sub │ └──────────┘
│ │ subscribe ┌──────────┐
│ │ ◀──────────────── │ SSE Srv 2│
└───────────┘ └──────────┘
Each SSE server subscribes to relevant Redis channels. When any service publishes an event, all SSE servers receive it and fan out to their connected clients:
import Redis from 'ioredis';
const sub = new Redis();
sub.subscribe('events:notifications');
sub.on('message', (channel, message) => {
// Broadcast to all connected SSE clients
for (const client of connectedClients) {
client.res.write(`data: ${message}\n\n`);
}
});
For per-user channels, subscribe to events:user:${userId} and route messages to the correct connection.
Connection Limits and HTTP/2#
Under HTTP/1.1, browsers enforce a 6-connection-per-domain limit. A single SSE connection counts toward this limit, which can starve other requests. Mitigations:
- Use HTTP/2 — Connections are multiplexed, so the limit does not apply.
- Use a subdomain — Serve SSE from
sse.example.comto get a separate connection pool. - Multiplex event types — Use a single SSE connection with named events instead of multiple connections.
HTTP/2 is the real fix. If you are still on HTTP/1.1 and need SSE, prioritize the upgrade.
Use Cases#
SSE excels in these scenarios:
- Notifications — New messages, mentions, system alerts pushed to the browser.
- Live feeds — Social media timelines, news tickers, stock prices.
- Progress updates — File upload progress, CI/CD build status, long-running job tracking.
- Dashboard refresh — Real-time metrics and monitoring dashboards.
- AI streaming — LLM token-by-token response streaming (used by ChatGPT, Claude, and others).
- Collaborative cursors — Showing other users' cursor positions in a shared document.
The AI streaming use case has driven massive SSE adoption since 2023. Most LLM APIs use SSE to stream partial responses.
Common Pitfalls#
- Forgetting
Cache-Control: no-cache— Proxies and CDNs may buffer the stream, causing messages to arrive in batches. - Not handling
req.on('close')— Leaked intervals and memory when clients disconnect. - Missing
\n\nterminator — Messages will not be dispatched to the client until the double newline is sent. - Ignoring connection limits on HTTP/1.1 — Multiple SSE connections can freeze the browser tab.
- Not assigning event IDs — Without IDs, reconnection replays nothing and you lose messages.
Wrapping Up#
Server-Sent Events offer a simple, reliable, HTTP-native path to real-time server push. The built-in reconnection, Last-Event-ID resumption, and compatibility with HTTP/2 multiplexing make SSE the pragmatic choice for unidirectional streaming. Reserve WebSockets for truly bidirectional use cases. For everything else — notifications, live feeds, AI streaming — SSE is the right tool.
304 articles and guides at codelit.io/blog.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
Try these templates
Uber Real-Time Location System
Handles 5M+ GPS pings per second using H3 hexagonal geospatial indexing.
6 componentsReal-Time Collaborative Editor
Notion-like document editor with real-time collaboration, conflict resolution, and rich media.
9 componentsNetflix Video Streaming Architecture
Global video streaming platform with adaptive bitrate, CDN distribution, and recommendation engine.
10 componentsBuild this architecture
Generate an interactive architecture for Server in seconds.
Try it in Codelit →
Comments