Real-Time Architecture
Real-time isn't a feature β it's an architecture decision that changes everything: how you manage connections, how you handle state, how you deploy, and how you think about data consistency. Choosing the wrong real-time pattern costs you either performance or complexity. Choosing the right one makes your app feel alive.
The Real-Time Spectrum
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Technique β Latency β Direction β Complexity β
ββββββββββββββββββββΌβββββββββββββΌβββββββββββββββΌββββββββββββββββββββ
β Short Polling β High β Client β Srv β Low β
β Long Polling β Medium β Client β Srv β Medium β
β SSE β Low β Srv β Client β Low β
β WebSocket β Very Low β Bidirectionalβ High β
β WebTransport β Very Low β Bidirectionalβ Very High β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββShort Polling
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetch('/api/notifications');
setNotifications(await data.json());
}, 5000);
return () => clearInterval(interval);
}, []);Simple. Wasteful. Every 5 seconds you hit the server whether anything changed or not. For 10,000 users polling every 5s, that's 2,000 requests/second β most returning "no changes."
Long Polling
async function longPoll() {
try {
const response = await fetch('/api/notifications/poll', {
signal: AbortSignal.timeout(30_000),
});
const data = await response.json();
setNotifications(prev => [...data.new, ...prev]);
} catch (error) {
if (error.name !== 'AbortError') {
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
longPoll();
}The server holds the connection open until there's new data or a timeout. Better than polling for low-frequency updates, but each response requires a new HTTP connection.
Server-Sent Events (SSE)
function useSSE(url: string) {
const [data, setData] = useState(null);
useEffect(() => {
const source = new EventSource(url);
source.addEventListener('notification', (event) => {
setData(JSON.parse(event.data));
});
source.addEventListener('heartbeat', () => {
// Connection alive
});
source.onerror = () => {
source.close();
setTimeout(() => {
// Reconnect logic (EventSource auto-reconnects by default)
}, 3000);
};
return () => source.close();
}, [url]);
return data;
}SSE strengths:
- Built-in reconnection with
Last-Event-ID - Works over HTTP/2 (multiplexed, no connection limit)
- Text-based, simple server implementation
- Automatic browser reconnection
SSE limitations:
- Server β Client only (no bidirectional communication)
- Text only (no binary)
- Limited to ~6 connections per domain on HTTP/1.1
When SSE Is Enough
Notifications β SSE β
(server pushes, client reads)
Live scores β SSE β
(server pushes updates)
Stock ticker β SSE β
(server pushes prices)
Chat messages β SSE β (need to send messages too)
Collaborative edit β SSE β (need bidirectional sync)
Gaming β SSE β (need low-latency bidirectional)WebSockets
Full-duplex, persistent TCP connection. Both client and server can send messages at any time.
class WebSocketManager {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private listeners = new Map<string, Set<Function>>();
private messageQueue: string[] = [];
connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.flushQueue();
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.listeners.get(message.type)?.forEach(fn => fn(message.payload));
};
this.ws.onclose = (event) => {
this.stopHeartbeat();
if (!event.wasClean) this.reconnect(url);
};
}
send(type: string, payload: unknown) {
const message = JSON.stringify({ type, payload });
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
this.messageQueue.push(message);
}
}
on(type: string, handler: Function) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type)!.add(handler);
return () => this.listeners.get(type)?.delete(handler);
}
private reconnect(url: string) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30_000);
this.reconnectAttempts++;
setTimeout(() => this.connect(url), delay);
}
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25_000);
}
private stopHeartbeat() {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
}
private flushQueue() {
while (this.messageQueue.length > 0) {
this.ws?.send(this.messageQueue.shift()!);
}
}
}Connection Lifecycle
Client Server
β β
βββββ HTTP Upgrade Request ββββββββββΆβ
βββββ 101 Switching Protocols βββββββ
β β
ββββββ WebSocket Connection βββββββββΆβ
β β
βββββ { type: "subscribe", βββββββββΆβ
β channel: "feed" } β
β β
ββββββ { type: "post:new", βββββββββ
β payload: {...} } β
β β
βββββ ping ββββββββββββββββββββββββββΆβ
ββββββ pong βββββββββββββββββββββββββ
β β
βββββ close βββββββββββββββββββββββββΆβ
ββββββ close ββββββββββββββββββββββββSocket.io vs Native WebSocket
| Factor | Native WebSocket | Socket.io |
|---|---|---|
| Fallback | None | Long polling β WebSocket |
| Reconnection | Manual | Built-in with backoff |
| Rooms/namespaces | Manual | Built-in |
| Binary | Supported | Supported |
| Broadcasting | Manual | Built-in |
| Bundle size | 0 KB (browser native) | ~45 KB |
| Protocol overhead | Minimal | Higher (custom protocol) |
Recommendation: Use native WebSocket for new projects where you control both ends. Use Socket.io when you need fallbacks for legacy environments or want rapid prototyping with rooms.
WebTransport
The next generation β built on HTTP/3 (QUIC). Supports unreliable datagrams (no head-of-line blocking), multiple streams, and lower latency than WebSocket.
const transport = new WebTransport('https://example.com/wt');
await transport.ready;
const writer = transport.datagrams.writable.getWriter();
await writer.write(new TextEncoder().encode('fast unreliable message'));
const reader = transport.datagrams.readable.getReader();
const { value } = await reader.read();Still emerging (2024+), but ideal for gaming, live video, and latency-critical applications.
Real-Time Data Patterns
Event Sourcing on the Frontend
Instead of storing current state, store a sequence of events and derive state:
type FeedEvent =
| { type: 'POST_ADDED'; post: Post }
| { type: 'POST_LIKED'; postId: string; userId: string }
| { type: 'POST_DELETED'; postId: string }
| { type: 'COMMENT_ADDED'; postId: string; comment: Comment };
function feedReducer(state: FeedState, event: FeedEvent): FeedState {
switch (event.type) {
case 'POST_ADDED':
return { ...state, posts: [event.post, ...state.posts] };
case 'POST_LIKED':
return {
...state,
posts: state.posts.map(p =>
p.id === event.postId
? { ...p, likeCount: p.likeCount + 1 }
: p
),
};
case 'POST_DELETED':
return { ...state, posts: state.posts.filter(p => p.id !== event.postId) };
default:
return state;
}
}CRDTs for Collaboration
Conflict-free Replicated Data Types allow multiple users to edit simultaneously without coordination:
ββββββββββββ ββββββββββββ
β User A β β User B β
β β β β
β "Hello" β β "Hello" β
β β β β β β
β Insert β β Insert β
β " World" β β "!" β
β at pos 5 β β at pos 5 β
β β β βββββββ β β β
β βββββββΌββββββββββΆβ ββββββββββββΌβββββ β
β β βCRDT β β β
β β βMergeβ β β
β βββββββΌβββββββββββ ββββββββββββΌβββββ β
β βΌ β βββββββ β βΌ β
β"Hello β β"Hello β
β World!" β β World!" β
ββββββββββββ ββββββββββββBoth users converge to the same state without conflict resolution logic. Libraries like Yjs and Automerge implement this:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://collab.example.com', 'doc-123', doc);
const text = doc.getText('content');
text.observe(event => {
editor.setValue(text.toString());
});
function handleLocalEdit(delta: string, position: number) {
text.insert(position, delta);
}Operational Transform (OT)
The predecessor to CRDTs, used by Google Docs:
User A: Insert 'X' at position 3
User B: Delete character at position 1
Server receives A first, then transforms B:
B's position shifts: delete at position 1 (no change needed since insert was after)
Server receives B first, then transforms A:
A's position shifts: insert 'X' at position 2 (since char before was deleted)OT requires a central server for transformation. CRDTs are decentralized but use more memory.
Presence Systems
Show who's online, who's typing, who's viewing the same document:
interface PresenceState {
userId: string;
status: 'online' | 'idle' | 'offline';
cursor?: { x: number; y: number };
lastActive: number;
}
function usePresence(channel: string) {
const [peers, setPeers] = useState<Map<string, PresenceState>>(new Map());
useEffect(() => {
const ws = wsManager;
ws.send('presence:join', { channel });
const unsub1 = ws.on('presence:update', (state: PresenceState) => {
setPeers(prev => new Map(prev).set(state.userId, state));
});
const unsub2 = ws.on('presence:leave', ({ userId }: { userId: string }) => {
setPeers(prev => {
const next = new Map(prev);
next.delete(userId);
return next;
});
});
const idleTimer = setInterval(() => {
ws.send('presence:heartbeat', { channel });
}, 10_000);
return () => { unsub1(); unsub2(); clearInterval(idleTimer); };
}, [channel]);
return peers;
}Notification Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Notification System β
β β
β βββββββββββ ββββββββββββββββ ββββββββββββββ β
β β Backend βββββΆβ Notification βββββΆβ WebSocket β β
β β Events β β Service β β Gateway β β
β βββββββββββ ββββββββ¬ββββββββ βββββββ¬βββββββ β
β β β β
β βΌ βΌ β
β ββββββββββββββββββ ββββββββββββββ β
β β Push Service β β Client β β
β β (FCM / APNs) β β (browser) β β
β ββββββββββββββββββ βββββββ¬βββββββ β
β β β
β ββββββΌβββββββ β
β β Toast / β β
β β Badge / β β
β β Sound β β
β βββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββClient-Side Notification Manager
type NotificationType = 'info' | 'success' | 'warning' | 'error';
interface Notification {
id: string;
type: NotificationType;
title: string;
message: string;
timestamp: number;
read: boolean;
actionUrl?: string;
}
const useNotificationStore = create<NotificationStore>((set, get) => ({
notifications: [],
unreadCount: 0,
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => {
const entry: Notification = {
...notification,
id: crypto.randomUUID(),
timestamp: Date.now(),
read: false,
};
set(state => ({
notifications: [entry, ...state.notifications].slice(0, 100),
unreadCount: state.unreadCount + 1,
}));
},
markAsRead: (id: string) => {
set(state => ({
notifications: state.notifications.map(n =>
n.id === id ? { ...n, read: true } : n
),
unreadCount: Math.max(0, state.unreadCount - 1),
}));
},
}));Scaling WebSockets
The Problem
WebSocket connections are stateful. A user connected to Server A can't receive messages published from Server B.
Solution: Redis Pub/Sub
ββββββββββββ ββββββββββββ ββββββββββββ
β WS Srv 1 β β WS Srv 2 β β WS Srv 3 β
β (1000 β β (1000 β β (1000 β
β conns) β β conns) β β conns) β
ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ
β β β
βββββββββββββββββΌββββββββββββββββ
β
ββββββββΌβββββββ
β Redis β
β Pub/Sub β
βββββββββββββββWhen Server 1 needs to broadcast to a user on Server 2, it publishes to Redis. Server 2 subscribes and forwards to the connected client.
Sticky Sessions
Alternatively, route the same user to the same server using a load balancer:
Load Balancer (hash userId β server)
β
βββ userId: abc β Server 1
βββ userId: def β Server 2
βββ userId: ghi β Server 3Simple but fragile β if Server 1 dies, all its users must reconnect and may lose messages.
Optimistic Updates in Real-Time
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Optimistic Update Flow (Chat Message) β
β β
β 1. User types message, hits send β
β 2. Message appears immediately (optimistic) β
β ββ Gray checkmark (sending) β
β 3. Send via WebSocket to server β
β 4. Server confirms receipt β
β ββ Single checkmark (sent) β
β 5. Server confirms delivery to recipient β
β ββ Double checkmark (delivered) β
β 6. Recipient reads β
β ββ Blue double checkmark (read) β
β β
β On failure at step 3/4: β
β ββ Red exclamation (failed) + retry button β
βββββββββββββββββββββββββββββββββββββββββββββββββββββOffline-First with Sync
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Offline-First Architecture β
β β
β ββββββββββββ ββββββββββββ ββββββββββββ β
β β UI βββββΆβ Local βββββΆβ Sync β β
β β Layer ββββββ Store ββββββ Engine β β
β β β β (IndexedDB)β β β β
β ββββββββββββ ββββββββββββ ββββββ¬ββββββ β
β β β
β ββββββΌββββββ β
β β Server β β
β β (when β β
β β online) β β
β ββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββConflict Resolution Strategies
| Strategy | Description | Best For |
|---|---|---|
| Last-write-wins | Latest timestamp wins | Simple, low-conflict data |
| First-write-wins | First change is preserved | Append-only data |
| Merge | Combine both changes | Non-overlapping fields |
| CRDT | Mathematically convergent | Collaborative editing |
| Manual | Show conflict to user | Critical data (medical, financial) |
Real-time architecture is a spectrum. Start with the simplest pattern that meets your latency requirements. Polling works for dashboards. SSE works for notifications. WebSockets work for chat. CRDTs work for collaboration. The architect's job is matching the pattern to the problem β not reaching for the most complex tool every time.