WebSockets vs SSE vs Long Polling: Real-Time Communication Explained
Learn how WebSockets, Server-Sent Events, and long polling work. When to use each for real-time features like chat, notifications, and live dashboards.
Your app needs live updates. A chat message arrives. A trade executes. A build finishes. The user should see it immediately — without refreshing.
There are three ways to do this, and each has a different tradeoff.
The Problem with Normal HTTP
Standard HTTP is request-response. The client asks, the server answers. The server can't push data to the client unprompted.
For real-time features, you need either:
- The client to ask repeatedly (polling)
- A persistent connection the server can push over (WebSockets, SSE)
Long Polling
The simplest approach. The client makes a request. If there's no new data, the server holds the request open until something happens (or a timeout).
# FastAPI long polling endpoint
import asyncio
from fastapi import FastAPI
from datetime import datetime
app = FastAPI()
pending_updates: dict[str, asyncio.Event] = {}
new_data: dict[str, list] = {}
@app.get("/updates/{client_id}")
async def get_updates(client_id: str):
event = asyncio.Event()
pending_updates[client_id] = event
try:
# Wait up to 30 seconds for new data
await asyncio.wait_for(event.wait(), timeout=30)
updates = new_data.pop(client_id, [])
return {"updates": updates}
except asyncio.TimeoutError:
# No new data — client should reconnect
return {"updates": []}
finally:
pending_updates.pop(client_id, None)
def push_update(client_id: str, data: dict):
if client_id in pending_updates:
new_data.setdefault(client_id, []).append(data)
pending_updates[client_id].set()// Client
async function pollForUpdates(clientId) {
while (true) {
const res = await fetch(`/updates/${clientId}`);
const { updates } = await res.json();
if (updates.length > 0) {
handleUpdates(updates);
}
// Reconnects immediately after response
}
}Pros: Works everywhere. No special browser support needed. Works through proxies and firewalls.
Cons: High server resource usage — connections stay open. Latency equal to time between updates. Not efficient for high-frequency updates.
Best for: Low-frequency updates, simple notification systems, legacy browser support.
Server-Sent Events (SSE)
One-way persistent connection from server to client. The server streams data. The client listens.
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json
app = FastAPI()
async def event_stream(channel: str):
async for event in subscribe_to_channel(channel):
# SSE format: "data: ...\n\n"
yield f"data: {json.dumps(event)}\n\n"
# Send keep-alive every 30s to prevent timeout
if no_event_for_30s:
yield ": keep-alive\n\n"
@app.get("/stream/{channel}")
async def stream_events(channel: str):
return StreamingResponse(
event_stream(channel),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disable Nginx buffering
}
)// Client — built-in EventSource API
const es = new EventSource("/stream/trades");
es.onmessage = (event) => {
const trade = JSON.parse(event.data);
updateTradeDisplay(trade);
};
es.onerror = () => {
// Browser automatically reconnects
console.log("Reconnecting...");
};The browser's EventSource API handles reconnection automatically. On disconnect, it reconnects with a Last-Event-ID header so you can resume where you left off.
Pros: Simple. Browser handles reconnection. Works over plain HTTP/2. Lower overhead than WebSockets for one-way streams.
Cons: Server-to-client only — client can't send messages on the same connection. HTTP/1.1 limits to 6 connections per domain. Not supported in IE11 (but polyfills exist).
Best for: Live feeds (news, stock prices, social media), notifications, progress bars, live logs, dashboards where data only flows server→client.
WebSockets
Full-duplex connection. Both client and server can send messages at any time over the same connection.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from typing import dict
app = FastAPI()
class ConnectionManager:
def __init__(self):
self.connections: dict[str, list[WebSocket]] = {}
async def connect(self, room: str, ws: WebSocket):
await ws.accept()
self.connections.setdefault(room, []).append(ws)
async def disconnect(self, room: str, ws: WebSocket):
self.connections[room].remove(ws)
async def broadcast(self, room: str, message: dict):
for ws in self.connections.get(room, []):
await ws.send_json(message)
manager = ConnectionManager()
@app.websocket("/ws/chat/{room}")
async def chat_ws(ws: WebSocket, room: str, user_id: str):
await manager.connect(room, ws)
try:
while True:
data = await ws.receive_json()
# Broadcast to all clients in the room
await manager.broadcast(room, {
"user": user_id,
"message": data["message"],
"timestamp": datetime.utcnow().isoformat()
})
except WebSocketDisconnect:
await manager.disconnect(room, ws)
await manager.broadcast(room, {"user": user_id, "event": "left"})// Client
const ws = new WebSocket("wss://api.example.com/ws/chat/room-123?user_id=alice");
ws.onopen = () => {
ws.send(JSON.stringify({ message: "Hello!" }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
renderMessage(msg);
};
ws.onclose = () => {
// Implement reconnect with exponential backoff
setTimeout(reconnect, 1000);
};Pros: Bi-directional. Low latency (no HTTP overhead per message). Real-time feel. Efficient for high-frequency messages.
Cons: More complex server state — connections are stateful. Doesn't work transparently through all proxies. Harder to scale horizontally (connections are sticky).
Best for: Chat, multiplayer games, collaborative editing (Google Docs), trading platforms, anything needing client→server pushes.
Scaling WebSockets
WebSocket connections are stateful — a client stays connected to one server. When you have multiple servers, messages from Server A can't reach clients on Server B.
Fix: use a pub/sub layer (Redis) to fan out messages:
import aioredis
redis = aioredis.from_url("redis://localhost")
@app.websocket("/ws/chat/{room}")
async def chat_ws(ws: WebSocket, room: str):
await manager.connect(room, ws)
# Subscribe to this room's Redis channel
async with redis.pubsub() as pubsub:
await pubsub.subscribe(f"room:{room}")
async def receive_from_redis():
async for message in pubsub.listen():
if message["type"] == "message":
await ws.send_text(message["data"])
async def receive_from_client():
while True:
data = await ws.receive_text()
# Publish to Redis — all servers in the cluster receive it
await redis.publish(f"room:{room}", data)
await asyncio.gather(receive_from_redis(), receive_from_client())Every server subscribes to Redis. A message published from any server reaches all connected clients.
Comparison
| Long Polling | SSE | WebSockets | |
|---|---|---|---|
| Direction | Client→Server→Client | Server→Client | Both |
| Connection | Reopened per response | Persistent | Persistent |
| Protocol | HTTP | HTTP | WS (upgraded HTTP) |
| Browser support | All | All modern | All modern |
| Reconnect | Manual | Automatic | Manual |
| Proxy friendly | Yes | Yes | Sometimes |
| Scaling | Easy | Easy | Needs pub/sub |
| Best for | Simple notifications | Live feeds | Chat, games |
When to Use Each
Long polling: You need compatibility with very old browsers or restrictive corporate proxies. Low-frequency updates (once per minute or less).
SSE: Push-only data streams — notifications, live feeds, progress updates, streaming AI responses (ChatGPT uses SSE). Much simpler than WebSockets when you don't need client messages.
WebSockets: Chat, collaborative tools, games, trading platforms — anything where the client also needs to send real-time data.
Key Takeaways
- Long polling: client keeps asking — simple, but inefficient for frequent updates
- SSE: server pushes over HTTP — simple, automatic reconnect, one-way only
- WebSockets: full-duplex persistent connection — bi-directional, lowest latency, most complex
- For notifications and feeds, SSE is often the right choice (simpler than WebSockets)
- For chat and collaborative features, WebSockets are necessary
- WebSocket horizontal scaling needs a pub/sub layer (Redis) to fan out messages
- Most real-time features are server→client only — reach for SSE before WebSockets
Pick the simplest option that meets your needs. SSE handles most real-time use cases with less complexity than WebSockets.
Related reading: Message Queues Explained · Rate Limiting Your API · API Gateway Pattern
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
GraphQL vs REST: Which API Style Should You Use?
Learn the real differences between GraphQL and REST APIs. When GraphQL solves real problems, when REST is the right choice, and how to decide for your project.
TCP, UDP, HTTP, gRPC, WebSockets: When to Use Each
Learn the difference between TCP, UDP, HTTP, gRPC, and WebSockets. Practical guide on picking the right protocol for your backend system.
DNS Explained: How the Internet Finds Websites
Learn how DNS translates domain names to IP addresses step by step. Covers DNS resolution, record types, caching, and why it matters for system design.