Skip to main content

SSE vs WebSockets vs Long Polling: Real-Time APIs 2026

·APIScout Team
ssewebsocketsreal-timeapistreaming

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-stream over a regular HTTP connection; no protocol upgrades, no special proxy configuration
  • WebSockets require a protocol upgrade (ws:// or wss://) — 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 EventSource API 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 CaseBest ChoiceReason
LLM streaming completionsSSEHTTP, works everywhere, AI SDK uses SSE
Live dashboard metricsSSEServer-to-client, auto-reconnect, lightweight
Real-time chatWebSocketBidirectional, low latency
Collaborative editingWebSocketLow-latency bidirectional with conflict resolution
Multiplayer game stateWebSocketHigh-frequency bidirectional, binary support
Push notificationsSSESimple, one-way, auto-reconnect
File upload progressSSEServer reports progress to client
Financial tick data (trading)WebSocketBinary encoding, ultra-low latency
Corporate proxy environmentsLong PollingHTTP/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

Comments