SSE vs WebSockets vs Long Polling: Real-Time APIs 2026
SSE vs WebSockets vs Long Polling: Choosing a Real-Time API Strategy in 2026
TL;DR
Every real-time API needs to push data to clients — the question is how. Server-Sent Events (SSE) is the dominant choice in 2026 for server-to-client streaming: it's an HTTP standard, works over HTTP/2 multiplexing, works natively in Cloudflare Workers and edge runtimes, and requires zero special handling for load balancers or proxies. WebSockets are the choice when you need bidirectional, low-latency communication — real-time collaboration, multiplayer games, trading platforms. Long polling is the legacy fallback — it works everywhere but creates unnecessary load; use it only when SSE isn't available or when you need compatibility with very old clients. The LLM streaming revolution has cemented SSE as the dominant real-time API pattern for 2026: every major AI provider (OpenAI, Anthropic, Google) uses SSE for streaming completions.
Key Takeaways
- SSE is HTTP — it uses
Content-Type: text/event-streamover a regular HTTP connection; no protocol upgrades, no special proxy configuration - WebSockets require a protocol upgrade (
ws://orwss://) — some corporate proxies block non-HTTP protocols, CDNs need special configuration - SSE is server-to-client only — the client cannot send data over the SSE connection; it uses separate HTTP requests for that
- WebSockets are bidirectional — one connection for both directions; better for chat, collaboration, multiplayer
- Edge runtime support: SSE ✅ native everywhere; WebSockets ✅ Cloudflare/Deno support; Long polling ✅ everywhere
- HTTP/2 multiplexing means SSE connections don't hold a separate TCP socket — multiple SSE streams share one HTTP/2 connection
The Real-Time Data Problem
REST APIs are request-response: the client asks, the server answers. This model breaks down for real-time use cases:
- Chat — users need to see new messages without refreshing
- Live dashboards — metrics should update automatically
- AI completions — LLM output streams token by token; waiting for the full response is poor UX
- Collaborative editing — other users' changes appear in real time
- Notifications — server pushes alerts without the client polling
Three protocols solve this — each with different trade-offs.
Server-Sent Events (SSE)
How SSE Works
SSE uses a regular HTTP connection with a Content-Type: text/event-stream header. The server sends a stream of events in a simple text format; the client listens with the EventSource API.
Server (Node.js / Hono):
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
const app = new Hono()
app.get('/events', async (c) => {
return streamSSE(c, async (stream) => {
let id = 0
while (true) {
// Send a named event with JSON data
await stream.writeSSE({
id: String(id++),
event: 'update',
data: JSON.stringify({ timestamp: Date.now(), value: Math.random() }),
})
await stream.sleep(1000) // Send every second
}
})
})
Client (Browser):
const eventSource = new EventSource('/events', {
withCredentials: true, // Include cookies for auth
})
// Listen for the default 'message' event
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
updateDashboard(data)
}
// Listen for named events
eventSource.addEventListener('update', (event) => {
const data = JSON.parse(event.data)
updateMetric(data)
})
// Handle errors — EventSource auto-reconnects on disconnect
eventSource.onerror = (event) => {
console.error('SSE connection error:', event)
// EventSource reconnects automatically after a few seconds
}
// Close when done
eventSource.close()
SSE's Built-in Features
The SSE protocol has features built into the spec that are often overlooked:
Named events:
event: user.joined
data: {"userId":"abc123","name":"Alice"}
event: message.created
data: {"id":"msg-456","text":"Hello","authorId":"abc123"}
Last-Event-ID for reconnection:
id: 42
data: {"value":100}
When the SSE connection drops, the browser's EventSource automatically reconnects and sends the Last-Event-ID header with the last received ID. Your server can use this to replay missed events — no client-side reconnection logic needed.
Retry control:
retry: 5000
data: reconnect in 5 seconds if disconnected
SSE for LLM Streaming
The ai package's toDataStreamResponse() uses SSE under the hood:
// Server (Next.js App Router)
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o'),
messages,
})
return result.toDataStreamResponse()
// Sends: Content-Type: text/event-stream
// Events: data: {"type":"text-delta","textDelta":"Hello"}
}
// Client
import { useChat } from 'ai/react'
const { messages } = useChat() // Handles SSE internally
SSE Limitations
- Server-to-client only — the client can't send data over the SSE connection; use separate HTTP requests for bidirectional communication
- One stream per connection (HTTP/1.1) — with HTTP/1.1, browsers limit 6 connections per domain; with HTTP/2, SSE streams are multiplexed and this limit disappears
- No binary data — SSE sends text; if you need binary (images, audio), encode as base64 or use a separate endpoint
WebSockets
How WebSockets Work
WebSockets start as an HTTP upgrade request (Upgrade: websocket), then switch to a bidirectional binary protocol. Once upgraded, either side can send frames at any time.
Server (Node.js with ws):
import { WebSocketServer } from 'ws'
import { createServer } from 'http'
const httpServer = createServer(app)
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', (ws, req) => {
console.log('Client connected')
// Send a welcome message
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }))
// Receive messages from client
ws.on('message', (data) => {
const message = JSON.parse(data.toString())
// Broadcast to all connected clients
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'broadcast',
from: message.userId,
text: message.text,
timestamp: Date.now(),
}))
}
})
})
ws.on('close', () => console.log('Client disconnected'))
ws.on('error', (err) => console.error('WebSocket error:', err))
})
Client:
const ws = new WebSocket('wss://api.example.com/ws')
ws.onopen = () => {
// Send data to the server over the same connection
ws.send(JSON.stringify({ type: 'join', roomId: 'room-123', userId: 'user-abc' }))
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
handleServerMessage(data)
}
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason)
// Implement reconnection logic manually — WebSocket doesn't auto-reconnect
setTimeout(() => reconnect(), 3000)
}
WebSocket Use Cases
WebSockets win when you need:
- Sub-100ms bidirectional communication — real-time collaboration (Google Docs-style), multiplayer games
- Binary protocol efficiency — audio/video streaming, financial tick data with custom binary encoding
- Very high message frequency — trading platforms sending 1,000+ messages/second per client
WebSocket Infrastructure Challenges
Unlike SSE, WebSockets require infrastructure awareness:
CDN / Load Balancer challenges:
- Must support WebSocket protocol upgrade (not all CDNs do by default)
- Requires "sticky sessions" if your WebSocket server maintains state in memory
- Cloudflare: WebSocket support requires Business plan or higher for some features
- AWS ALB: requires specific listener protocol and idle timeout settings
For Cloudflare Workers, WebSockets require using CF-WebSocket APIs:
// Cloudflare Workers WebSocket
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.headers.get('Upgrade') === 'websocket') {
const pair = new WebSocketPair()
const [client, server] = Object.values(pair)
server.accept()
server.send(JSON.stringify({ type: 'connected' }))
server.addEventListener('message', (event) => {
// Handle message
})
return new Response(null, { status: 101, webSocket: client })
}
return new Response('Expected WebSocket', { status: 400 })
}
}
Long Polling
How Long Polling Works
Long polling is an HTTP request that the server holds open until there's data to send (or a timeout), then closes. The client immediately makes another request. It simulates push by repeatedly pulling:
// Client
async function longPoll(lastEventId: string) {
while (true) {
try {
const response = await fetch(`/poll?since=${lastEventId}`, {
signal: AbortSignal.timeout(30_000), // 30-second timeout
})
if (response.ok) {
const events = await response.json()
events.forEach(event => processEvent(event))
lastEventId = events.at(-1)?.id ?? lastEventId
}
} catch (error) {
// Timeout or network error — retry after brief delay
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
}
Why Long Polling Is Mostly Obsolete
Long polling creates 2N HTTP requests for N events (one request to receive each batch, one immediately after). It holds server worker threads or processes open while waiting. It's harder to scale than SSE or WebSockets.
When long polling still makes sense:
- Clients behind corporate proxies that block SSE or WebSocket connections
- Very infrequent updates (once every few minutes) where SSE connection overhead isn't justified
- Legacy client environments that predate
EventSourceAPI support
Socket.io: When to Use a Higher-Level Abstraction
Socket.io wraps WebSockets with automatic fallback (to long polling if WebSockets fail), namespaces, rooms, and reconnection handling. It adds ~30kB to your client bundle.
Use Socket.io when:
- You need rooms/namespaces for broadcast channels
- You want automatic fallback for restrictive proxy environments
- Your team prefers the Socket.io event model over raw WebSocket frames
- You're building a chat or notification system where rooms are a natural fit
Don't use Socket.io when:
- You're building for edge runtimes (Socket.io doesn't run in Cloudflare Workers)
- You need pure WebSocket performance (Socket.io's abstraction layer adds overhead)
- You're streaming AI completions (SSE is the right protocol; Socket.io adds unnecessary complexity)
// Socket.io server — room-based broadcasting
import { Server } from 'socket.io'
const io = new Server(httpServer)
io.on('connection', (socket) => {
socket.on('join-room', (roomId) => {
socket.join(roomId)
socket.to(roomId).emit('user-joined', { userId: socket.id })
})
socket.on('chat-message', (message) => {
io.to(message.roomId).emit('chat-message', {
...message,
timestamp: Date.now(),
})
})
})
Scaling Real-Time Connections
SSE Scaling
SSE connections are standard HTTP connections — they scale like your HTTP server:
// Each SSE connection holds an open HTTP response
// With HTTP/2, multiple SSE streams share one TCP connection
// Horizontal scaling: any instance can serve any SSE client (stateless)
// For event broadcasting across instances, use a Redis pub/sub:
import { createClient } from 'redis'
const subscriber = createClient({ url: process.env.REDIS_URL })
await subscriber.connect()
app.get('/events', async (c) => {
return streamSSE(c, async (stream) => {
const channel = `user:${getUserId(c)}`
await subscriber.subscribe(channel, async (message) => {
await stream.writeSSE({ data: message })
})
// Unsubscribe when client disconnects
c.req.raw.signal.addEventListener('abort', () => {
subscriber.unsubscribe(channel)
})
})
})
WebSocket Scaling
WebSockets require sticky sessions (or a shared message broker) because the connection is stateful:
Load Balancer
├── Server 1: User A WebSocket, User B WebSocket
├── Server 2: User C WebSocket, User D WebSocket
└── Server 3: User E WebSocket, User F WebSocket
Problem: User A (on Server 1) sends a message to User D (on Server 2)
Solution: Redis pub/sub as message broker between servers
// Redis adapter for Socket.io — handles cross-instance broadcasting
import { createAdapter } from '@socket.io/redis-adapter'
import { createClient } from 'redis'
const pubClient = createClient({ url: process.env.REDIS_URL })
const subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()])
io.adapter(createAdapter(pubClient, subClient))
// Now io.to('room-123').emit() works across all instances
For Cloudflare Workers, the Durable Objects API provides a per-room stateful WebSocket hub:
export class ChatRoom implements DurableObject {
private sessions: Map<string, WebSocket> = new Map()
async fetch(request: Request) {
const pair = new WebSocketPair()
const [client, server] = Object.values(pair)
this.sessions.set(crypto.randomUUID(), server)
server.accept()
server.addEventListener('message', (event) => {
// Broadcast to all sessions in this room (Durable Object)
this.sessions.forEach(ws => ws.send(event.data))
})
return new Response(null, { status: 101, webSocket: client })
}
}
Decision Guide
| Use Case | Best Choice | Reason |
|---|---|---|
| LLM streaming completions | SSE | HTTP, works everywhere, AI SDK uses SSE |
| Live dashboard metrics | SSE | Server-to-client, auto-reconnect, lightweight |
| Real-time chat | WebSocket | Bidirectional, low latency |
| Collaborative editing | WebSocket | Low-latency bidirectional with conflict resolution |
| Multiplayer game state | WebSocket | High-frequency bidirectional, binary support |
| Push notifications | SSE | Simple, one-way, auto-reconnect |
| File upload progress | SSE | Server reports progress to client |
| Financial tick data (trading) | WebSocket | Binary encoding, ultra-low latency |
| Corporate proxy environments | Long Polling | HTTP/1.1 compatible fallback |
Methodology
- Protocol specifications: W3C Server-Sent Events (W3C Living Standard), RFC 6455 (WebSocket)
- npm download data from npmjs.com API, March 2026 weekly averages
- Edge runtime compatibility data from Cloudflare Workers and Deno documentation
- Sources: MDN Web Docs, Hono streaming documentation, ws npm package documentation, Vercel AI SDK documentation
Explore real-time API libraries and WebSocket packages on APIScout — compare ws, Socket.io, Pusher, and Ably alternatives.
Related: API Caching Strategies: HTTP to Redis 2026 · Hono vs Fastify vs Express: API Framework 2026 · Building TypeScript API Client SDKs 2026