You're building a notifications system. Users should see new alerts the moment they happen — no page refresh, no polling every 5 seconds. You google "real-time web" and get two answers: Server-Sent Events (SSE) and WebSockets. Both keep connections open. Both deliver data in real time. But they solve different problems, and picking the wrong one means either over-engineering a simple feature or under-powering a complex one.
This guide breaks down exactly how each one works, when to use which, and shows you working code for both. By the end, you'll know which one fits your use case without second-guessing.
What Are Server-Sent Events (SSE)?
Server-Sent Events is a browser API that lets the server push updates to the client over a single, long-lived HTTP connection. The client opens the connection once using theEventSource API, and the server streams text-based messages down whenever it has new data.
Think of it like subscribing to a news feed. You open the connection, sit back, and the server sends you updates as they happen. You don't send anything back — you just listen.
Key characteristics of SSE:
- • Unidirectional — server to client only
- • Standard HTTP — uses regular HTTP with
Content-Type: text/event-stream - • Auto-reconnect — browser handles reconnection automatically if connection drops
- • Event IDs — server can tag events with IDs so client resumes from last received event
- • Simple API — just
new EventSource(url)on the client - • Text only — UTF-8 text data (no binary)
What Are WebSockets?
WebSockets provide a full-duplex, bidirectional communication channel between client and server over a single TCP connection. Unlike SSE, both sides can send messages at any time without waiting for the other to finish.
The connection starts as an HTTP request (the "upgrade handshake"), then switches to the WebSocket protocol (ws:// orwss://). After that, it's a persistent pipe — no HTTP overhead per message.
Key characteristics of WebSockets:
- • Bidirectional — both client and server send messages freely
- • Custom protocol — uses ws:// after HTTP upgrade handshake
- • No auto-reconnect — you implement reconnection logic yourself
- • Binary + text — supports both UTF-8 text and binary frames
- • Low latency — no HTTP headers per message after handshake
- • More complex — requires WebSocket server, proxy configuration, state management
For a full deep dive into how WebSockets work and how they differ from regular HTTP, check out our WebSockets vs HTTP guide.
How SSE Works Under the Hood
SSE is surprisingly simple. The client makes a regular HTTP GET request withAccept: text/event-stream. The server responds with Content-Type: text/event-stream and keeps the connection open, writing new data whenever it's available.
The flow:
1. Client opens connection:
GET /events HTTP/1.1
Accept: text/event-stream
2. Server responds and keeps connection open:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
3. Server streams messages as they happen:
data: {"type": "notification", "message": "New order received"}
data: {"type": "notification", "message": "Payment confirmed"}
4. If connection drops → browser auto-reconnects with:
GET /events HTTP/1.1
Last-Event-ID: 42SSE Stream Format
Each message in the stream consists of one or more fields, each on its own line. Messages are separated by a blank line (double newline \n\n):
data: This is a simple message
data: {"user": "alice", "action": "login"}
id: 42
event: user-activity
data: Line one
data: Line two (multi-line message)
: this is a comment (ignored by client)
retry: 5000The four fields:
- •
data:— The message payload. Multiple data lines get joined with newlines. - •
event:— Custom event type (defaults to "message"). Client listens withaddEventListener("user-activity", ...) - •
id:— Event ID for resuming after reconnection. Browser sendsLast-Event-IDheader on reconnect. - •
retry:— Reconnection delay in milliseconds. Server tells client how long to wait before reconnecting.
SSE vs WebSocket: Head-to-Head Comparison
Here's a direct comparison across every dimension that matters:
| Feature | SSE | WebSocket |
|---|---|---|
| Direction | Unidirectional (server → client) | Bidirectional (both ways) |
| Connection | Standard HTTP (long-lived GET) | Upgraded TCP connection (ws://) |
| Protocol | HTTP/1.1 or HTTP/2 | WebSocket protocol (RFC 6455) |
| Auto-reconnection | Built-in (browser handles it) | Manual (you implement it) |
| Browser support | All modern browsers (no IE) | All modern browsers (including IE 10+) |
| Complexity | Simple — few lines of code | More complex — state management, heartbeats |
| Per-message overhead | Small HTTP framing | 2-14 bytes per frame (minimal) |
| Scalability | Excellent with HTTP/2 multiplexing | Requires sticky sessions or pub/sub |
| Data format | Text only (UTF-8) | Text + binary (ArrayBuffer, Blob) |
| Proxy/firewall friendly | Yes — standard HTTP | Sometimes blocked by corporate firewalls |
| Best use case | Notifications, feeds, streaming AI | Chat, gaming, collaborative editing |
The takeaway: if you only need the server to push data to the client, SSE is simpler and works with existing HTTP infrastructure. If the client needs to send frequent messages back to the server in real time, WebSockets are the way to go.
When to Use Server-Sent Events
SSE shines when the communication pattern is "server has something new to tell the client." If the client only needs to listen and doesn't need to send data back through the same connection, SSE is almost always the better choice because it's simpler to implement, debug, and scale.
Notifications & alerts
Push new notifications to users without polling. Bell icon updates, toast messages, system alerts.
Live feeds & timelines
Social media feeds, news tickers, activity logs that update in real time.
Stock tickers & pricing
Stream price updates to the UI. Client just displays — doesn't need to send trades through the same channel.
Log streaming & monitoring
Stream server logs, build output, or deployment progress to a dashboard in real time.
AI/LLM streaming responses
ChatGPT, Claude, and most AI APIs stream token-by-token responses using SSE. The user's prompt goes via regular HTTP POST, and the response streams back over SSE.
Progress updates
File upload progress, background job status, ETL pipeline progress bars.
When to Use WebSockets
WebSockets are the right choice when both client and server need to send messages frequently and in real time. If the client is just listening, they're overkill. But when bidirectional communication is a genuine requirement, nothing else comes close.
Real-time chat
Users send messages and receive them from others simultaneously. Both directions are equally active.
Multiplayer gaming
Player inputs go to server, game state comes back — both at high frequency with minimal latency.
Collaborative editing
Google Docs-style applications where every keystroke from every user needs to be broadcast to all others instantly.
Live auctions & trading
Users place bids (client → server) and see others' bids (server → client) in real time.
IoT device control
Send commands to devices and receive sensor data back — both directions continuously.
The rule of thumb: if the client only needs to receive, use SSE. If the client needs to send and receive in real time, use WebSockets.
Code Examples: SSE vs WebSocket
Let's implement the same feature — a live notifications stream — with both SSE and WebSockets so you can see the difference in complexity.
SSE Server (Node.js/Express)
const express = require('express');
const app = express();
// Store connected clients
const clients = new Set();
app.get('/events', (req, res) => {
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering
// Handle client reconnection — check Last-Event-ID
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
// Send missed events since lastEventId
sendMissedEvents(res, parseInt(lastEventId));
}
// Add client to connected set
clients.add(res);
// Send initial connection confirmation
res.write('event: connected\ndata: {"status": "ok"}\n\n');
// Clean up on disconnect
req.on('close', () => {
clients.delete(res);
});
});
// Function to broadcast to all connected clients
let eventId = 0;
function broadcast(eventType, data) {
eventId++;
const message = `event: ${eventType}\nid: ${eventId}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => client.write(message));
}
// Example: send notification every time something happens
app.post('/api/orders', (req, res) => {
// ... process order ...
broadcast('notification', {
type: 'new-order',
message: 'New order #1234 received',
timestamp: Date.now()
});
res.json({ success: true });
});
app.listen(3000);SSE Client (Browser — EventSource API)
// Connect to SSE endpoint — that's it
const eventSource = new EventSource('/events');
// Listen for default "message" events
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Message:', data);
};
// Listen for custom event types
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
showNotification(data.message);
});
// Connection opened
eventSource.onopen = () => {
console.log('Connected to SSE');
};
// Handle errors (browser auto-reconnects)
eventSource.onerror = (error) => {
if (eventSource.readyState === EventSource.CONNECTING) {
console.log('Reconnecting...');
} else {
console.error('SSE error:', error);
}
};
// Close when done
// eventSource.close();WebSocket Server (Same Feature)
const { WebSocketServer } = require('ws');
const express = require('express');
const http = require('http');
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
// Send connection confirmation
ws.send(JSON.stringify({ event: 'connected', status: 'ok' }));
// Handle incoming messages from client
ws.on('message', (message) => {
const data = JSON.parse(message);
// Handle client messages (not needed for notifications, but required by WS)
console.log('Client said:', data);
});
// Handle heartbeat to detect dead connections
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => {
clients.delete(ws);
});
});
// Heartbeat interval to detect disconnected clients
setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
// Broadcast notifications
function broadcast(eventType, data) {
const message = JSON.stringify({ event: eventType, ...data });
clients.forEach(client => {
if (client.readyState === 1) { // OPEN
client.send(message);
}
});
}
server.listen(3000);WebSocket Client (Same Feature)
let ws;
let reconnectAttempts = 0;
function connect() {
ws = new WebSocket('wss://yourserver.com/ws');
ws.onopen = () => {
console.log('Connected to WebSocket');
reconnectAttempts = 0;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.event === 'notification') {
showNotification(data.message);
}
};
// Manual reconnection logic (SSE does this for free)
ws.onclose = () => {
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(connect, delay);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
ws.close();
};
}
connect();Notice the difference?
For the same notification feature, the SSE version is shorter, has no heartbeat logic, no manual reconnection, and doesn't need a separate WebSocket server. The WebSocket version works too — but it's more code for a feature that only needs server-to-client communication.
SSE Message Format Explained
The SSE protocol is text-based and line-oriented. Each field starts with a keyword followed by a colon. Messages are terminated by a blank line. Here's a complete breakdown:
data:
The message payload. Can span multiple lines — each line needs its own data: prefix. Multiple data lines are joined with newlines when received by the client.
data: Simple single-line message
data: First line
data: Second line
data: Third line
// Client receives: "First line\nSecond line\nThird line"
data: {"user": "alice", "score": 42}
// Client receives JSON string, use JSON.parse(event.data)event:
Custom event type name. Without it, events fire on the onmessage handler. With it, you listen using addEventListener.
event: user-login
data: {"user": "alice", "time": "2026-07-01T10:30:00Z"}
event: new-order
data: {"orderId": "ORD-5678", "total": 99.99}
// Client:
// eventSource.addEventListener('user-login', handler)
// eventSource.addEventListener('new-order', handler)id:
Sets the last event ID. If the connection drops, the browser sends this ID in the Last-Event-ID header on reconnect so the server can resume from that point.
id: 1001
event: notification
data: {"message": "First notification"}
id: 1002
event: notification
data: {"message": "Second notification"}
// After disconnect, browser reconnects with:
// Last-Event-ID: 1002
// Server sends events 1003+ onwardsretry:
Sets the reconnection timeout in milliseconds. If the connection drops, the browser waits this long before attempting to reconnect. Default is typically 3 seconds.
retry: 5000 data: Connection established. Will retry in 5s if dropped. // Server can dynamically adjust retry based on load: retry: 10000 data: Server under heavy load, backing off reconnection.
Complete message example with all fields:
id: 42
event: price-update
retry: 3000
data: {"symbol": "AAPL", "price": 198.52, "change": "+1.23"}
Note the blank line at the end — that's what tells the parser this message is complete.
Limitations of Server-Sent Events
SSE is great, but it's not without tradeoffs. Here are the limitations you should know about before choosing it:
6 connections per domain (HTTP/1.1)
Browsers limit simultaneous HTTP/1.1 connections to the same origin to 6. Each SSE stream uses one connection. If a user opens your app in 6 tabs, all connection slots are used up and no more requests can go through — including regular API calls. Fix: Use HTTP/2, which multiplexes all streams over a single TCP connection.
No binary data support
SSE only transmits UTF-8 text. You can't send images, audio, video frames, or protocol buffers directly. You'd need to Base64-encode binary data (adding 33% overhead) or send a URL that the client fetches separately.
Unidirectional only
The client cannot send data back through the SSE connection. If the client needs to communicate with the server, it must make separate HTTP requests (POST, PUT, etc.). This is fine for most use cases but adds latency for high-frequency client-to-server messaging.
No custom headers on EventSource
The native EventSource API doesn't support custom headers (like Authorization). You'd need to pass tokens via query parameters or cookies, or use a polyfill library like eventsource on npm that supports custom headers.
No IE support
Internet Explorer never implemented EventSource. If you need IE support (unlikely in 2026), you'd need a polyfill. All other modern browsers — Chrome, Firefox, Safari, Edge — support it fully.
Connection timeout risk
Some proxies and load balancers close idle connections after 30-60 seconds. If the server doesn't send data frequently, the connection may be terminated. Fix: Send periodic comment lines (: keepalive\n\n) as heartbeats.
Common Mistakes Developers Make
These are the pitfalls I see most often when teams implement SSE or WebSockets for the first time:
1. Using WebSockets when SSE would suffice
The most common mistake. A notification system that only pushes from server to client doesn't need WebSockets. You've added complexity — heartbeats, reconnection logic, WebSocket server setup, proxy configuration — for no benefit. Start with SSE and switch to WebSockets only when you need bidirectional communication.
2. Not handling reconnection properly
With WebSockets, forgetting to implement reconnection means your app silently dies when the connection drops. With SSE, forgetting to set event IDs means the client reconnects but misses events that happened during downtime. Always test what happens when you pull the network cable.
3. Forgetting to disable response buffering
Nginx, Apache, and many reverse proxies buffer responses by default. Your SSE events pile up in the proxy buffer and arrive in batches instead of immediately. Fix: set X-Accel-Buffering: no header or configure proxy_buffering off in Nginx.
4. Not cleaning up connections on the server
If you don't detect when clients disconnect and remove them from your subscribers list, you'll leak memory. The server keeps trying to write to dead connections. Always listen for the close event and remove the client from your set.
5. Using HTTP/1.1 with many SSE connections
Running into the 6-connection limit is frustrating and hard to debug. The page loads slowly, AJAX calls hang, and you don't know why. Always serve SSE over HTTP/2 in production, where streams are multiplexed over a single connection.
6. Sending too much data per event
SSE events should be small and focused. Sending a 50KB JSON blob per event defeats the purpose of streaming. If you need to send large payloads, send a notification with an ID and let the client fetch the full data via a regular API call.
Best Practices
Follow these guidelines to build reliable real-time features regardless of which technology you choose:
1. Always use event IDs for SSE
Set the id: field on every event. This lets the client resume from where it left off after a reconnection. Without IDs, clients miss events that happened while they were disconnected. Use incrementing integers or timestamps.
2. Implement heartbeats for both
For SSE, send comment lines (: heartbeat\n\n) every 15-30 seconds to prevent proxy timeouts. For WebSockets, implement ping/pong frames to detect dead connections and trigger reconnection.
3. Use exponential backoff for reconnection
SSE handles this automatically (with the retry: field), but for WebSockets, implement exponential backoff: 1s, 2s, 4s, 8s... up to a maximum of 30s. This prevents thousands of clients from reconnecting simultaneously and overwhelming the server.
4. Serve SSE over HTTP/2
HTTP/2 eliminates the 6-connection limit by multiplexing streams. A single TCP connection handles all SSE streams, regular requests, and asset loading. This is the default with modern servers (Nginx 1.9.5+, Node.js with http2 module).
5. Keep event payloads small
Send only what's needed for the UI to update. Include an ID or reference that the client can use to fetch full details if needed. Aim for under 1KB per event. This keeps the stream responsive and reduces bandwidth.
6. Use typed events
Don't send everything as a generic "message" event. Use the event: field in SSE or a type property in WebSocket messages. This lets clients subscribe only to events they care about and makes the code more maintainable.
7. Handle authentication properly
For SSE, use cookies (they're sent automatically) or pass a short-lived token as a query parameter. For WebSockets, authenticate during the HTTP upgrade handshake using cookies or a token in the first message. Never pass long-lived secrets in URLs.
8. Plan for horizontal scaling
Both SSE and WebSocket connections are stateful — tied to a specific server instance. When scaling horizontally, use a pub/sub system (Redis, NATS, Kafka) to broadcast events across all server instances. Each server subscribes and forwards events to its connected clients.
Frequently Asked Questions
What is the main difference between SSE and WebSockets?
SSE is unidirectional — the server pushes data to the client over a standard HTTP connection. WebSockets are bidirectional — both client and server can send messages to each other simultaneously over a persistent TCP connection with its own ws:// protocol.
When should I use SSE instead of WebSockets?
Use SSE when you only need server-to-client communication: notifications, live feeds, stock tickers, log streaming, AI streaming responses (like ChatGPT), and real-time dashboards. SSE is simpler to implement, works over standard HTTP, auto-reconnects, and doesn't need special proxy configuration.
When should I use WebSockets instead of SSE?
Use WebSockets when you need bidirectional communication: real-time chat, multiplayer gaming, collaborative editing (Google Docs), live auctions, or any scenario where the client needs to send frequent messages back to the server without making separate HTTP requests.
Does SSE support automatic reconnection?
Yes. The EventSource API has built-in automatic reconnection. If the connection drops, the browser automatically reconnects after a configurable delay and sends the Last-Event-ID header so the server can resume from where it left off. WebSockets require you to implement reconnection logic manually.
Can SSE send binary data?
No. SSE only supports UTF-8 text data. If you need to send binary data (images, audio, video frames), you must use WebSockets or encode the binary as Base64 (which adds ~33% overhead). For most real-time text-based use cases, this limitation doesn't matter.
What is the 6-connection limit in SSE?
In HTTP/1.1, browsers limit simultaneous connections to the same domain to 6. Each SSE stream uses one connection. If a user opens multiple tabs, they can exhaust all 6 slots. HTTP/2 multiplexes streams over a single connection, effectively removing this limitation.
Is SSE or WebSocket better for ChatGPT-style streaming?
SSE is the standard choice. OpenAI, Anthropic, and most AI APIs use SSE to stream token-by-token responses. The user's prompt goes via regular HTTP POST, and the response streams back over SSE. It's simpler and fits the unidirectional pattern perfectly.
Do SSE connections work behind load balancers and proxies?
Yes. Since SSE uses standard HTTP, it works with existing load balancers, proxies, CDNs, and firewalls without special configuration. WebSockets require proxy support for the ws:// protocol upgrade, which can be problematic with older infrastructure.
Related Articles & Tools
Conclusion
The decision between SSE and WebSockets comes down to one question: does the client need to send real-time messages back to the server through the same connection?
If the answer is no — and it usually is for notifications, feeds, dashboards, and AI streaming — use SSE. It's simpler, works with existing HTTP infrastructure, handles reconnection automatically, and scales naturally with HTTP/2.
If the answer is yes — chat, gaming, collaborative editing — use WebSockets. Accept the added complexity because the bidirectional nature is genuinely required.
Don't overthink it. Start with SSE for server-push use cases. If you later find yourself building workarounds because the client needs to send frequent messages back, that's when you upgrade to WebSockets. The worst outcome is reaching for WebSockets by default and spending days configuring something that SSE handles in 20 lines of code.
