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.
Explore more system design guides at codelit.io/blog.
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 componentsContinue learning
Go deeper on distributed systems
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Database Internals
Alex Petrov · 2019
Explains B-trees, LSM trees and consensus protocols so you can predict how a datastore will behave.
4.6 (560)#2 in Management Information SystemsMicroservices Patterns
Chris Richardson · 2018
Sagas as running Java code: compensating transactions, outbox, queries across services.
4.6 (705)Continuous API Management
Mehdi Medjaoui, Erik Wilde, Ronnie Mitra, Mike Amundsen · 2021
How versioning, deprecation and governance play out across a whole API landscape over years.
4.0 (10)GraphQL in Action
Samer Buna · 2021
Builds schema, resolvers and N+1 fixes end to end rather than stopping at a toy query.
4.0 (18)