Skip to content

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

Anurag Verma

7 min read

Server-Sent Events vs WebSockets in 2026: Picking the Right Real-Time Transport

Sponsored

Share

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:

  1. Cookie-based auth: If your app already uses HTTP-only cookies, SSE inherits them automatically. The cleanest approach.
  2. Query parameter token: Pass the token as a URL query param. Works but keep tokens short-lived.
  3. 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:

FeatureRecommended
Push notificationsSSE
Live dashboard / metricsSSE
LLM response streamingSSE
Order status updatesSSE
Chat applicationWebSockets
Collaborative document editingWebSockets
Multiplayer gameWebSockets
Real-time cursor positionsWebSockets

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

Enjoyed it? Pass it on.

Share this article.

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.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored