Web Development · Real-time
SSE vs WebSockets: The Right Real-Time Transport
Not every real-time feature needs WebSockets. Server-Sent Events handle most push scenarios with far less complexity. How to choose, with working code.
Anurag Verma
7 min read
Sponsored
A client asks for “real-time notifications.” A developer reaches for WebSockets. That decision adds a stateful connection layer, requires a compatible server setup, and introduces reconnection handling. For what might be a one-way feed of events, SSE could handle this in 20 lines.
The choice between Server-Sent Events, WebSockets, and long polling matters because they have meaningfully different complexity profiles. Getting it wrong usually means either over-engineering (WebSockets for a notification feed) or running into limits (SSE for a chat app).
The Three Options
Server-Sent Events (SSE) is a browser API built on top of HTTP. The client opens a single GET request; the server keeps the connection open and streams events down it. One direction only: server to client. Auto-reconnect is built into the browser. Works with standard HTTP/2 multiplexing.
WebSockets is a separate protocol (WS:// or WSS://). After an HTTP handshake upgrades the connection, both sides can send messages in either direction. Stateful, bidirectional, and lower overhead per message once connected.
Long polling is a fallback: the client sends a request, the server holds it open until there’s something to send (or a timeout), then closes it. The client immediately opens another request. It works everywhere but creates high overhead from repeated request/response cycles.
When to Use What
The decision tree is short:
- Bidirectional communication needed? (chat, collaborative editing, gaming) → WebSockets
- Server-to-client only? (notifications, live feeds, LLM streaming, progress bars) → SSE
- Need to support very old browsers or aggressive corporate proxies? → Long polling as fallback
Most real-time features are server-to-client only. The user isn’t sending a stream of data to the server; the server is pushing updates to the user. That’s SSE’s use case.
Server-Sent Events in Practice
The browser-side API is simple:
const es = new EventSource('/api/events');
es.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
es.onerror = (error) => {
// Browser auto-reconnects after a brief delay
console.error('SSE error, will reconnect', error);
};
// Listen to named events
es.addEventListener('order-update', (event) => {
const order = JSON.parse(event.data);
updateOrderUI(order);
});
// Clean up when the component unmounts (React example)
return () => es.close();
The EventSource object reconnects automatically on disconnect. The server can also send a retry: field to control the reconnection delay in milliseconds, behavior specified directly in the WHATWG HTML standard rather than something a library bolts on.
Server Side: Node.js with Express
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// Send a comment every 15s as a keepalive
const keepalive = setInterval(() => {
res.write(': keepalive\n\n');
}, 15000);
// Example: send an event
const sendEvent = (eventName, data) => {
res.write(`event: ${eventName}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Subscribe this connection to whatever push mechanism you use
const unsubscribe = eventBus.subscribe(req.user.id, sendEvent);
req.on('close', () => {
clearInterval(keepalive);
unsubscribe();
});
});
The event format is plain text:
event: order-update
data: {"orderId":"123","status":"shipped","trackingNumber":"1Z999AA"}
: keepalive
data: {"message":"New notification"}
Each event ends with two newlines. Fields: event: (optional name), data: (required), id: (for reconnection), retry: (ms before reconnect).
SSE With Authentication
EventSource doesn’t support custom headers, which means you can’t pass a Bearer token the standard way. Three options:
- Cookie-based auth: If your app already uses HTTP-only cookies, SSE inherits them automatically. The cleanest approach.
- Query parameter token: Pass the token as a URL query param. Works but keep tokens short-lived.
- One-time token exchange: Before connecting, call a REST endpoint to get a short-lived SSE token, then use that in the EventSource URL.
// Option 2: token in URL (use only with short-lived tokens)
const token = await getShortLivedToken();
const es = new EventSource(`/api/events?token=${token}`);
// Server side: validate from query
app.get('/api/events', async (req, res) => {
const token = req.query.token;
const user = await validateShortLivedToken(token);
if (!user) {
res.status(401).end();
return;
}
// ... setup SSE
});
WebSockets in Practice
WebSockets make sense when the client sends data to the server at stream frequency, not just occasional REST calls but continuous or frequent messages.
// Browser
const ws = new WebSocket('wss://api.yourapp.com/ws');
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'subscribe', channel: 'orders' }));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
handleMessage(message);
};
ws.onclose = (event) => {
// Unlike SSE, you need to implement reconnection yourself
if (!event.wasClean) {
setTimeout(reconnect, 2000);
}
};
function reconnect() {
// Re-establish the WebSocket connection
}
// Server side with ws library
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws, req) => {
// Authenticate from the request headers or URL token
const user = authenticate(req);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message.type === 'subscribe') {
subscribeUserToChannel(user.id, message.channel, ws);
} else if (message.type === 'chat') {
broadcastToChannel(message.channel, {
type: 'message',
user: user.name,
text: message.text,
});
}
});
ws.on('close', () => {
unsubscribeUser(user.id);
});
ws.on('error', console.error);
});
Unlike SSE, WebSockets need you to handle reconnection, heartbeats, and connection state management on the client side. Libraries like reconnecting-websocket handle this, but it’s additional surface area.
HTTP/2 and SSE Scaling
Under HTTP/1.1, each SSE connection occupies a full TCP connection. Browsers cap connections per domain at 6, which means at most 6 SSE tabs to the same origin simultaneously. In practice this isn’t often a real-world limit, but it’s worth knowing.
Under HTTP/2, SSE connections are multiplexed over a single TCP connection. The browser limit no longer applies. If your server and CDN support HTTP/2 (most do in 2026), SSE scales without the HTTP/1.1 connection restriction.
WebSockets don’t benefit from HTTP/2 multiplexing; each WebSocket is its own connection. For massive fan-out (thousands of concurrent users), SSE over HTTP/2 often scales more cheaply than WebSockets. Either way, fan-out past a single app server usually needs a shared layer behind it, see message queues vs pub/sub vs event streaming for how to pick one.
LLM Response Streaming
LLM APIs (OpenAI, Anthropic, Gemini) stream tokens using SSE. When you build a chat interface that shows tokens arriving in real time, you’re using SSE from the LLM API to your backend, and typically SSE from your backend to the browser.
// Streaming LLM response to the browser via SSE
app.get('/api/chat', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.flushHeaders();
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: req.query.message }],
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
res.write(`data: ${JSON.stringify({ token: content })}\n\n`);
}
}
res.write('data: [DONE]\n\n');
res.end();
});
This is the most common SSE use case in 2026. WebSockets would add no value here. The user types a message (one REST call), then receives a stream of tokens (server-to-client only). SSE is the right fit. If you’re also instrumenting these calls, our guide to observability for AI apps covers tracing streamed LLM calls specifically.
Practical Summary
The actual choice in most projects:
| Feature | Recommended |
|---|---|
| Push notifications | SSE |
| Live dashboard / metrics | SSE |
| LLM response streaming | SSE |
| Order status updates | SSE |
| Chat application | WebSockets |
| Collaborative document editing | WebSockets |
| Multiplayer game | WebSockets |
| Real-time cursor positions | WebSockets |
Start with SSE unless you need bidirectional streaming. It’s simpler to implement, simpler to deploy (standard HTTP, no protocol upgrade required, works through most proxies and CDNs without special config), and simpler to debug. If the feature requirements change and you need bidirectionality, migrating from SSE to WebSockets is straightforward: the server-side push logic stays the same.
The failure mode to avoid is using WebSockets by default because “we might need bidirectionality later.” That’s the kind of future-proofing that adds complexity now for requirements that often never materialize.
Frequently asked questions
- How do I decide between SSE and WebSockets?
- Check which direction the data flows. If only the server needs to push updates, notifications, live feeds, progress bars, AI responses, use SSE. If the client also needs to send frequent messages back on the same live connection, chat, multiplayer games, collaborative editing, use WebSockets. Most features people build with WebSockets are actually one-directional and would be simpler as SSE.
- Does SSE reconnect automatically if the connection drops?
- Yes. The browser's EventSource API reconnects on its own after a disconnect, using a server-adjustable retry delay sent in the stream. WebSockets have no equivalent built in: you have to detect the close event and write your own reconnect and resubscribe logic, which is real additional surface area to test and maintain.
- Can SSE handle authentication if EventSource doesn't support custom headers?
- Yes, with one of three approaches: rely on HTTP-only cookies if your app already uses them (SSE inherits cookies automatically, the cleanest option), pass a short-lived token as a URL query parameter, or exchange a REST call for a one-time SSE token before opening the connection. Avoid putting long-lived credentials in the URL since query strings can end up in server logs.
- Why do AI chat interfaces stream responses over SSE instead of WebSockets?
- Because the traffic is one request followed by a one-way stream of tokens back, exactly SSE's use case. The client sends a single message (a normal HTTP request), then only receives, it doesn't need to send anything else until the response finishes. Adding a bidirectional WebSocket here would add a stateful protocol and a connection to manage for no benefit, which is why OpenAI, Anthropic, and Google's own streaming APIs use SSE under the hood.
Sources
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored