Web Development · Real-time
Server-Sent Events vs WebSockets in 2026: Picking the Right Real-Time Transport
Not every real-time feature needs WebSockets. Server-Sent Events handle most push scenarios with far less complexity. Here's how to choose, and what each approach looks like in actual 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.
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.
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.
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.
Sponsored
More from this category
More from Web Development
CSS Anchor Positioning: Tooltips and Popovers Without JavaScript
gRPC in 2026: When to Use It Instead of REST or GraphQL
k6 Load Testing: Performance Testing Your APIs Before Users Find the Problems
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored