Real-Time Data: WebSockets, SSE, and When You Actually Need Them
Understanding real-time data patterns — from WebSockets to Server-Sent Events, Firebase Realtime Database, and knowing when polling is good enough.

Most applications that claim to need real-time data do not actually need real-time data. They need data that feels current — updated within a few seconds, not a few milliseconds. The distinction matters because true real-time architectures introduce complexity in connection management, state synchronization, scaling, and error handling that are entirely unnecessary if your users would be perfectly happy with a five-second delay.
I have built genuinely real-time features — live order tracking for a restaurant platform, collaborative editing interfaces, live dashboards — and I have also built features where I initially over-engineered with WebSockets and later replaced them with polling that refreshes every ten seconds. Nobody noticed the change. Starting with the simplest approach and adding complexity only when users actually experience a problem is not just pragmatic — it is the responsible engineering choice.
When You Actually Need Real-Time
True real-time is warranted when the value of information degrades rapidly with latency.
Chat and messaging. Users expect messages to appear within a second. A five-second delay makes a conversation feel broken. Real-time is not optional here.
Collaborative editing. Google Docs-style simultaneous editing requires sub-second synchronization to avoid conflicting edits. The complexity here goes beyond just transport — you need operational transformation or CRDTs for conflict resolution.
Live dashboards with operational impact. A monitoring dashboard where an engineer is watching for anomalies during a deployment needs real-time updates. A business analytics dashboard viewed once a day does not.
Gaming and interactive experiences. Multiplayer games, live auctions, real-time polls — anything where multiple users interact with shared state simultaneously.
Financial trading. Price feeds, order book updates, position changes. Milliseconds matter here, and the architecture reflects that.
When You Do Not Need Real-Time
Social media feeds. Twitter and Instagram feel real-time, but they use a combination of polling on focus and push notifications. The feed does not update while you are looking at it — you pull to refresh.
E-commerce inventory. "Only 3 left!" does not need to update in real-time. Checking at page load and at checkout is sufficient.
Notification counts. The red badge showing "5 new notifications" can be polled every 30 seconds. Users do not notice if a notification appears 30 seconds after it was created.
Content updates. Blog posts, product listings, user profiles — any data that changes infrequently. Poll on page load or use HTTP cache headers with revalidation.
Polling: The Underrated Default
Polling — making periodic HTTP requests to check for new data — is the simplest approach and works for more use cases than most developers assume.
Simple Polling
function usePolledData<T>(url: string, intervalMs: number = 5000) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let active = true;
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (active) setData(result);
} catch (e) {
if (active) setError(e as Error);
}
};
fetchData(); // Initial fetch
const interval = setInterval(fetchData, intervalMs);
return () => {
active = false;
clearInterval(interval);
};
}, [url, intervalMs]);
return { data, error };
}
Smart Polling with Conditional Requests
HTTP conditional requests (If-None-Match with ETags or If-Modified-Since) let you poll efficiently. The server returns 304 Not Modified with no body if the data has not changed, reducing bandwidth to nearly zero for unchanged resources.
async function pollWithETag(url: string): Promise<{ data: any; changed: boolean }> {
const cachedETag = etagCache.get(url);
const headers: HeadersInit = {};
if (cachedETag) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch(url, { headers });
if (response.status === 304) {
return { data: dataCache.get(url), changed: false };
}
const etag = response.headers.get('ETag');
if (etag) etagCache.set(url, etag);
const data = await response.json();
dataCache.set(url, data);
return { data, changed: true };
}
When Polling Breaks Down
Polling stops being viable when:
- You need sub-second latency. Polling every 500ms is essentially a DoS attack on your own API.
- The data changes rarely but needs to be delivered instantly. Polling every 5 seconds for an event that happens once an hour wastes 719 requests per hour.
- You have thousands of clients. Each polling client is an independent connection. At scale, the HTTP overhead of establishing connections, parsing headers, and authenticating adds up.
Server-Sent Events: The Simpler Real-Time
Server-Sent Events (SSE) are the most underused real-time technology in web development. SSE provides a one-way stream from server to client over a standard HTTP connection. The browser handles reconnection automatically, and the protocol is trivially simple.
Server Implementation
// Express.js SSE endpoint
app.get('/events/orders/:restaurantId', (req, res) => {
const { restaurantId } = req.params;
// SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
// Send initial data
const initialOrders = getActiveOrders(restaurantId);
res.write(`data: ${JSON.stringify(initialOrders)}\n\n`);
// Subscribe to order changes
const unsubscribe = orderEmitter.on(`orders:${restaurantId}`, (order) => {
res.write(`event: orderUpdate\n`);
res.write(`data: ${JSON.stringify(order)}\n`);
res.write(`id: ${order.id}-${order.updatedAt}\n\n`);
});
// Heartbeat to detect dead connections
const heartbeat = setInterval(() => {
res.write(`: heartbeat\n\n`);
}, 30000);
// Cleanup on disconnect
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
});
Client Implementation
function useSSE(url: string) {
const [data, setData] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onopen = () => setConnected(true);
eventSource.onmessage = (event) => {
setData(JSON.parse(event.data));
};
eventSource.addEventListener('orderUpdate', (event) => {
const order = JSON.parse(event.data);
setData(prev => updateOrderInList(prev, order));
});
eventSource.onerror = () => {
setConnected(false);
// EventSource automatically reconnects
};
return () => eventSource.close();
}, [url]);
return { data, connected };
}
Why SSE Over WebSockets
Automatic reconnection. The browser's EventSource implementation handles reconnection with exponential backoff automatically. With WebSockets, you implement this yourself.
Works through proxies and load balancers. SSE uses standard HTTP, so it works through nginx, CloudFlare, and most reverse proxies without special configuration. WebSockets require explicit proxy support.
Simpler authentication. SSE connections carry cookies and can use standard HTTP authentication headers. WebSocket authentication requires sending credentials after the connection is established, which is an additional protocol to implement.
Built-in event types and IDs. The SSE protocol supports named events and message IDs, which enable the client to resume from where it left off after a reconnection.
SSE Limitations
- One-way only. Server to client. If you need bidirectional communication, SSE cannot do it. You can pair SSE with regular HTTP POST requests for client-to-server messages, which works well for many use cases.
- Browser connection limits. Browsers limit the number of simultaneous HTTP connections to a single domain (typically six). Each SSE connection counts toward this limit. HTTP/2 multiplexing mitigates this, but it is worth being aware of.
- No binary data. SSE is text-only. If you need to stream binary data (audio, video, files), use WebSockets or a separate mechanism.
WebSockets: Full Duplex Communication
WebSockets provide full-duplex communication — both client and server can send messages at any time without the overhead of HTTP request/response cycles. This is necessary when you need bidirectional, low-latency communication.
Server Implementation with ws
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server });
// Connection management
const rooms = new Map<string, Set<WebSocket>>();
wss.on('connection', (ws, req) => {
// Authenticate the connection
const token = new URL(req.url!, `http://${req.headers.host}`).searchParams.get('token');
const user = verifyToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
// Attach user data
(ws as any).userId = user.id;
ws.on('message', (raw) => {
try {
const message = JSON.parse(raw.toString());
handleMessage(ws, user, message);
} catch (e) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
// Remove from all rooms
rooms.forEach((clients, roomId) => {
clients.delete(ws);
if (clients.size === 0) rooms.delete(roomId);
});
});
// Heartbeat
(ws as any).isAlive = true;
ws.on('pong', () => { (ws as any).isAlive = true; });
});
// Dead connection detection
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!(ws as any).isAlive) {
ws.terminate();
return;
}
(ws as any).isAlive = false;
ws.ping();
});
}, 30000);
function handleMessage(ws: WebSocket, user: any, message: any) {
switch (message.type) {
case 'join_room':
joinRoom(ws, message.roomId);
break;
case 'leave_room':
leaveRoom(ws, message.roomId);
break;
case 'broadcast':
broadcastToRoom(message.roomId, {
type: 'message',
userId: user.id,
content: message.content,
timestamp: Date.now(),
}, ws);
break;
}
}
function joinRoom(ws: WebSocket, roomId: string) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket) {
const clients = rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(data);
clients.forEach((client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
}
server.listen(8080);
Client Implementation with Reconnection
class WebSocketClient {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private handlers = new Map<string, Set<(data: any) => void>>();
constructor(url: string) {
this.url = url;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.emit('connected', null);
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.emit(message.type, message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
this.ws.onclose = (event) => {
this.emit('disconnected', { code: event.code, reason: event.reason });
if (event.code !== 4001 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// Error is followed by close event, handle reconnection there
};
}
private scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => {
this.emit('reconnecting', { attempt: this.reconnectAttempts });
this.connect();
}, delay);
}
send(type: string, data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...data }));
}
}
on(event: string, handler: (data: any) => void) {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
return () => this.handlers.get(event)?.delete(handler);
}
private emit(event: string, data: any) {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
disconnect() {
this.maxReconnectAttempts = 0; // Prevent reconnection
this.ws?.close();
}
}
Reconnection Strategy
The reconnection logic deserves attention because it directly affects user experience and server load.
Exponential backoff prevents thundering herd problems. If your server restarts and 10,000 clients all reconnect simultaneously, the server crashes again. By adding jitter and exponential delays, connections spread out over time:
function getReconnectDelay(attempt: number): number {
const baseDelay = 1000;
const maxDelay = 30000;
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000; // Random jitter up to 1 second
return Math.min(exponentialDelay + jitter, maxDelay);
}
Connection state UI matters. Users need to know when they are disconnected and when the app is trying to reconnect. A small banner saying "Reconnecting..." is better than silently showing stale data.
Socket.IO: Convenience vs Control
Socket.IO is the most popular WebSocket library, and it provides significant convenience: automatic reconnection, room management, acknowledgments, and fallback to polling when WebSockets are unavailable. It also adds significant overhead and introduces a layer of abstraction that can mask problems.
When to Use Socket.IO
- Rapid prototyping. Socket.IO's API is simpler than raw WebSockets, and the built-in features save development time.
- When you need polling fallback. Some corporate networks block WebSocket connections. Socket.IO automatically falls back to HTTP long polling.
- Room and namespace management. If your real-time feature maps naturally to rooms (chat rooms, game lobbies, collaborative documents), Socket.IO's room abstractions save boilerplate.
When to Avoid Socket.IO
- High-performance applications. Socket.IO's protocol adds overhead to every message (framing, encoding, metadata). For high-throughput applications, raw WebSockets are measurably faster.
- When you need interoperability. Socket.IO is not standard WebSocket — a regular WebSocket client cannot connect to a Socket.IO server. If your clients include non-JavaScript environments (mobile apps, IoT devices), Socket.IO requires a client library for each platform.
- When you want to understand what is happening. Socket.IO abstracts away connection management, which is convenient until something goes wrong. Debugging a Socket.IO connection issue often means understanding the abstraction layer on top of the actual problem.
Firebase Realtime Database and Firestore
Firebase provides real-time capabilities without managing any WebSocket infrastructure. This is its primary value proposition — you trade control for convenience.
Firebase Realtime Database
import { getDatabase, ref, onValue, set } from 'firebase/database';
const db = getDatabase();
// Listen for real-time updates
const ordersRef = ref(db, `restaurants/${restaurantId}/orders`);
onValue(ordersRef, (snapshot) => {
const orders = snapshot.val();
updateUI(orders);
});
// Write data (triggers listeners on all connected clients)
await set(ref(db, `restaurants/${restaurantId}/orders/${orderId}`), {
status: 'preparing',
updatedAt: Date.now(),
});
The Realtime Database is a JSON tree. Every write to any node immediately propagates to every client listening on that node or any parent node. This is powerful but dangerous — a listener on a high-level node receives updates for every change in the entire subtree.
Firestore Real-Time Listeners
import { getFirestore, collection, onSnapshot, query, where } from 'firebase/firestore';
const db = getFirestore();
// Listen for active orders
const q = query(
collection(db, 'orders'),
where('restaurantId', '==', restaurantId),
where('status', 'in', ['pending', 'preparing', 'ready'])
);
const unsubscribe = onSnapshot(q, (snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') addOrder(change.doc.data());
if (change.type === 'modified') updateOrder(change.doc.data());
if (change.type === 'removed') removeOrder(change.doc.id);
});
});
Firestore's real-time listeners are more granular than the Realtime Database — you can listen to specific queries, not just paths. The docChanges() method tells you exactly what changed, which makes efficient UI updates possible.
Supabase Realtime
Supabase offers real-time capabilities on top of PostgreSQL, which gives you the query power of SQL with real-time updates. It uses PostgreSQL's replication features (logical replication and the WAL) to detect changes and broadcast them through WebSocket channels.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, key);
// Listen for changes to the orders table
const channel = supabase
.channel('orders')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'orders',
filter: `restaurant_id=eq.${restaurantId}`,
},
(payload) => {
console.log('Change type:', payload.eventType);
console.log('New data:', payload.new);
console.log('Old data:', payload.old);
}
)
.subscribe();
The advantage over Firebase is clear: your data lives in PostgreSQL with full SQL query capabilities, ACID transactions, and standard tooling. The real-time layer is an addition, not a replacement for your query engine. The trade-off is that Supabase Realtime is less battle-tested at scale than Firebase, and the real-time performance depends on PostgreSQL's replication throughput.
Scaling WebSocket Connections
A single server can handle tens of thousands of WebSocket connections, but scaling beyond one server introduces a coordination problem: a message published on Server A needs to reach clients connected to Server B.
The Pub/Sub Pattern
Redis pub/sub is the standard solution:
import { createClient } from 'redis';
const publisher = createClient();
const subscriber = createClient();
await publisher.connect();
await subscriber.connect();
// When a message comes in on any server, publish to Redis
function publishToRoom(roomId: string, message: any) {
publisher.publish(`room:${roomId}`, JSON.stringify(message));
}
// Each server subscribes and forwards to its local connections
await subscriber.subscribe(`room:${roomId}`, (message) => {
const data = JSON.parse(message);
broadcastToLocalClients(roomId, data);
});
This pattern lets you run multiple WebSocket servers behind a load balancer. Each server handles its own connections, and Redis coordinates cross-server communication.
Sticky Sessions
WebSocket connections are stateful — once established, they must remain on the same server. Load balancers need sticky sessions (also called session affinity) to route a client's WebSocket connection to the same server that handled the initial HTTP upgrade.
With nginx:
upstream websocket_servers {
ip_hash; # Sticky sessions based on client IP
server ws1.example.com:8080;
server ws2.example.com:8080;
}
server {
location /ws {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400; # 24 hours
}
}
Real-Time vs Near-Real-Time
There is a spectrum between "check for updates when the user refreshes the page" and "deliver every change within milliseconds." Most features fall somewhere in the middle.
| Approach | Latency | Complexity | Best For |
|---|---|---|---|
| Page refresh | User-initiated | None | Static content, infrequent changes |
| Polling (30s) | Up to 30 seconds | Low | Notification counts, dashboard updates |
| Polling (5s) | Up to 5 seconds | Low | Activity feeds, order status |
| Long polling | Sub-second | Moderate | Older systems, proxy-hostile environments |
| SSE | Sub-second | Moderate | Live feeds, notifications, one-way streams |
| WebSockets | Sub-second | High | Chat, collaboration, bidirectional real-time |
| WebRTC | Milliseconds | Very high | Video/audio, peer-to-peer |
The right approach is the simplest one that meets your latency requirement. Start with polling. If users complain about staleness, upgrade to SSE. If you need bidirectional communication, use WebSockets. Each step up the complexity ladder should be justified by a concrete user need, not a hypothetical future requirement.
Practical Recommendations
After building real-time features across several projects, these are the patterns I keep coming back to:
Default to SSE for one-way real-time. Most real-time features are server-to-client: order updates, notification streams, live data feeds. SSE handles all of these with less complexity than WebSockets.
Use WebSockets only for bidirectional needs. Chat, collaborative editing, multiplayer interactions — these genuinely need WebSockets. If your client only receives data, SSE is simpler.
Always implement reconnection with backoff. Connections drop. Networks switch. Servers restart. Your client needs to handle this gracefully, and your server needs to survive a reconnection storm.
Send diffs, not full state. Instead of sending the entire order list every time one order changes, send just the changed order. The client applies the diff to its local state. This reduces bandwidth and processing on both sides.
Have a fallback. If your WebSocket connection fails and cannot reconnect, the application should still be functional. A "last updated 30 seconds ago" indicator with manual refresh is better than a blank screen.
Monitor connection counts in production. WebSocket connections consume server resources (memory, file descriptors). A sudden spike in connections — from a client bug causing rapid reconnections, for example — can take down your server. Alert on connection count anomalies.
The goal is not to have the most sophisticated real-time architecture. The goal is to deliver data to users quickly enough that the application feels responsive, with the least amount of infrastructure complexity that achieves that feeling.
Danil Ulmashev
Full Stack Developer
Need a senior developer to build something like this for your business?