Real-Time Architecture: WebSockets, SSE, and Polling Patterns Explained
Every modern product — chat apps, dashboards, collaborative editors, trading platforms — needs data to arrive the instant it changes. Choosing the right real-time architecture determines your latency budget, infrastructure cost, and how gracefully you scale.
This guide compares the four main push/pull patterns, shows when to pick each one, and walks through production-grade scaling techniques.
The Four Real-Time Patterns#
1. Short Polling#
The client asks the server on a fixed interval. Simple, stateless, and wasteful.
setInterval(async () => {
const res = await fetch("/api/notifications");
const data = await res.json();
render(data);
}, 5000); // every 5 seconds
Use when: low-frequency updates (weather, stock closing prices), or when simplicity matters more than latency.
2. Long Polling#
The client sends a request and the server holds it open until new data is available, then responds. The client immediately reconnects.
async function subscribe() {
const res = await fetch("/api/events?since=" + lastEventId);
const data = await res.json();
lastEventId = data.id;
render(data);
subscribe(); // reconnect immediately
}
subscribe();
Use when: you need near-real-time delivery but cannot use WebSockets (firewalls, legacy proxies).
3. Server-Sent Events (SSE)#
A one-way, server-to-client stream over plain HTTP. Built into every browser via EventSource.
const source = new EventSource("/api/stream");
source.addEventListener("price-update", (e) => {
const payload = JSON.parse(e.data);
render(payload);
});
source.onerror = () => {
// browser auto-reconnects with Last-Event-ID header
};
Use when: you need push notifications architecture without bidirectional messaging — dashboards, live feeds, alerts.
4. WebSocket#
A full-duplex, persistent TCP connection. Both sides send frames at any time.
const ws = new WebSocket("wss://api.example.com/ws");
ws.onopen = () => ws.send(JSON.stringify({ type: "subscribe", channel: "trades" }));
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "trade") renderTrade(msg);
};
Use when: you need bidirectional, low-latency communication — chat, multiplayer games, collaborative editing.
WebSocket vs Polling — Quick Comparison#
| Criteria | Short Polling | Long Polling | SSE | WebSocket |
|---|---|---|---|---|
| Direction | Client → Server | Client → Server | Server → Client | Bidirectional |
| Latency | High (interval) | Medium | Low | Lowest |
| Connection overhead | New request each time | One held request | Single HTTP stream | Single TCP connection |
| Browser support | Universal | Universal | All modern | All modern |
| Scaling complexity | Low | Medium | Medium | High |
Scaling WebSocket Architecture#
A single Node.js process can hold ~50k concurrent WebSocket connections. Beyond that, you need a multi-node strategy.
Sticky Sessions#
Each connection must always route to the same backend process. Use a load balancer with IP-hash or cookie-based affinity.
upstream ws_backend {
ip_hash;
server app1:3000;
server app2:3000;
server app3:3000;
}
Redis Pub/Sub for Cross-Node Fan-Out#
When user A on node 1 sends a message to user B on node 3, you need a shared message bus.
import { createClient } from "redis";
import { WebSocketServer } from "ws";
const pub = createClient();
const sub = createClient();
await pub.connect();
await sub.connect();
const wss = new WebSocketServer({ port: 3000 });
const clients = new Map(); // userId -> ws
wss.on("connection", (ws, req) => {
const userId = authenticate(req);
clients.set(userId, ws);
ws.on("message", async (raw) => {
const msg = JSON.parse(raw);
// Publish to Redis so ALL nodes receive it
await pub.publish("chat", JSON.stringify({ to: msg.to, from: userId, body: msg.body }));
});
});
// Every node subscribes
await sub.subscribe("chat", (message) => {
const { to, from, body } = JSON.parse(message);
const recipient = clients.get(to);
if (recipient?.readyState === 1) {
recipient.send(JSON.stringify({ from, body }));
}
});
This is the fan-out pattern: one published message is delivered to every node, and each node checks whether it owns the target connection.
Presence Detection#
Knowing who is online is a core real-time systems requirement.
// On connect
await redis.hSet("presence", userId, JSON.stringify({ node: NODE_ID, connectedAt: Date.now() }));
// On disconnect
ws.on("close", () => {
redis.hDel("presence", userId);
pub.publish("presence", JSON.stringify({ userId, status: "offline" }));
});
// Query who is online
const online = await redis.hGetAll("presence");
Use a TTL-based heartbeat (e.g., every 30 seconds) to handle ungraceful disconnects.
Reconnection Strategies#
Connections drop. A production client must handle this transparently.
Exponential Backoff with Jitter#
function connectWithBackoff(url, attempt = 0) {
const ws = new WebSocket(url);
ws.onopen = () => {
attempt = 0; // reset on success
};
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** attempt, 30000);
const jitter = delay * (0.5 + Math.random() * 0.5);
setTimeout(() => connectWithBackoff(url, attempt + 1), jitter);
};
}
Jitter prevents a thundering herd when hundreds of clients reconnect at the same instant after a deploy.
Resume with Last Event ID#
After reconnecting, the client should request only missed events:
ws.onopen = () => {
ws.send(JSON.stringify({ type: "resume", lastEventId }));
};
The server keeps a short buffer (e.g., Redis Sorted Set with timestamps) and replays from that point.
Tools and Managed Services#
| Tool | Type | Best For |
|---|---|---|
| Socket.IO | Library (Node.js) | Auto-fallback (WS → polling), rooms, namespaces |
| Centrifugo | Self-hosted server | Language-agnostic real-time layer with Redis/Nats |
| Ably | Managed service | Global edge network, guaranteed ordering |
| Pusher | Managed service | Quick integration, presence channels |
| Liveblocks | Managed service | Collaborative/multiplayer state |
Socket.IO remains the most popular open-source option. It handles transport negotiation, reconnection, and room-based fan-out automatically:
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
const io = new Server(3000);
io.adapter(createAdapter(pubClient, subClient));
io.on("connection", (socket) => {
socket.join(`room:${socket.handshake.query.roomId}`);
socket.on("message", (data) => {
// Fan out to everyone in the room, across all nodes
io.to(`room:${data.roomId}`).emit("message", data);
});
});
Architecture Decision Checklist#
- Is the data flow unidirectional? → SSE is simpler than WebSocket.
- Do you need sub-100ms latency? → WebSocket or SSE, never polling.
- Are you behind restrictive proxies? → Long polling as fallback (Socket.IO does this for you).
- Scaling past one node? → Add Redis pub/sub or NATS for fan-out.
- Need presence? → Redis hash + heartbeat TTL.
- Budget constrained? → Centrifugo self-hosted. Need global edge? → Ably or Pusher.
Wrapping Up#
Real-time architecture is not a single technology — it is a spectrum of trade-offs between latency, complexity, and cost. Start with SSE or Socket.IO for most use cases, add Redis-backed fan-out when you scale horizontally, and reach for managed services when you need global distribution without the ops burden.
Build your next real-time feature with confidence. Explore more system design deep dives at codelit.io.
132 articles on system design at codelit.io/blog.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Related articles
Try these templates
Uber Real-Time Location System
Handles 5M+ GPS pings per second using H3 hexagonal geospatial indexing.
6 componentsReal-Time Collaborative Editor
Notion-like document editor with real-time collaboration, conflict resolution, and rich media.
9 componentsReal-Time Analytics Dashboard
Live analytics platform with event ingestion, stream processing, and interactive dashboards.
8 components
Comments