Real-Time Features Done Right
Real-Time Features Done Right
WebSockets vs SSE vs polling — architectural patterns for building notifications, live dashboards, and collaborative features in React/Next.js without painting yourself into a corner
The Real-Time Landscape
Every product eventually needs real-time features. Notifications that appear instantly. Dashboards that update without refresh. Collaborative editing where you see others typing. The question isn't whether you'll need it—it's whether your architecture will support it cleanly or become a maintenance nightmare.
I've seen teams choose WebSockets when polling would have been fine, implement polling when SSE was the obvious choice, and build custom solutions when existing tools would have saved months. Let's fix that.
Understanding Your Options
┌─────────────────────────────────────────────────────────────────────────────┐
│ REAL-TIME COMMUNICATION SPECTRUM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Polling │ │Long Polling │ │ SSE │ │ WebSockets │ │
│ │ │ │ │ │ │ │ │ │
│ │ Client ──► │ │ Client ──► │ │ Client ──► │ │ Client ◄─► │ │
│ │ Server │ │ Server │ │ Server ──► │ │ Server │ │
│ │ (repeat) │ │ (wait...) │ │ (stream) │ │ (bidir) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Complexity: Low Medium Low-Medium High │
│ Latency: High Medium Low Very Low │
│ Scalability: Varies Hard Easy Medium │
│ Infra needs: None None None WebSocket proxy │
│ Direction: Pull Pull Push Both │
│ │
│ Best for: Dashboards Legacy compat Notifications Chat, Collab │
│ with 30s+ when SSE/WS Live feeds Multiplayer │
│ tolerance not available Status updates Real-time sync │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Decision Tree
┌─────────────────────────────────────────────────────────────────────────────┐
│ WHICH REAL-TIME APPROACH? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Does the client need to send │
│ frequent real-time messages? │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ Yes Rarely No │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ ┌────────────────┐ │
│ │ │ │ How fresh must │ │
│ │ │ │ the data be? │ │
│ │ │ └───────┬────────┘ │
│ │ │ │ │
│ │ │ ┌────────┼────────┐ │
│ │ │ ▼ ▼ ▼ │
│ │ │ < 1 sec 1-30 sec > 30 sec │
│ │ │ │ │ │ │
│ │ │ ▼ ▼ ▼ │
│ │ │ SSE SSE Polling │
│ │ │ or is fine │
│ │ │ Polling │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Is it bidirectional real-time? │ │
│ │ (both sides sending frequently)│ │
│ └───────────────┬────────────────┘ │
│ │ │
│ ┌────────┼────────┐ │
│ ▼ ▼ ▼ │
│ Yes Maybe No │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ WebSockets SSE + SSE │
│ REST (server push) │
│ (hybrid) │
│ │
│ Common patterns: │
│ • Notifications → SSE │
│ • Live dashboard → SSE or Polling (depending on refresh rate) │
│ • Chat → WebSockets │
│ • Collaborative editing → WebSockets (or CRDTs over any transport) │
│ • Live sports scores → SSE │
│ • Stock tickers → WebSockets (if trading), SSE (if viewing) │
│ • Presence indicators → WebSockets │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Polling: Still Valid in 2024
When Polling Makes Sense
Don't let anyone tell you polling is always wrong. It's the right choice when:
- Data naturally updates on a slow cadence (hourly reports, daily stats)
- You need it to work everywhere with zero infrastructure changes
- The "real-time" requirement is actually "reasonably fresh" (< 1 minute)
- You're caching aggressively anyway
Smart Polling Implementation
// lib/hooks/use-polling.ts
import { useEffect, useRef, useCallback, useState } from 'react';
interface UsePollingOptions<T> {
fetcher: () => Promise<T>;
interval: number;
enabled?: boolean;
onError?: (error: Error) => void;
// Smart features
pauseWhenHidden?: boolean;
exponentialBackoff?: boolean;
maxBackoff?: number;
dedupe?: boolean;
}
export function usePolling<T>({
fetcher,
interval,
enabled = true,
onError,
pauseWhenHidden = true,
exponentialBackoff = true,
maxBackoff = 60000,
dedupe = true,
}: UsePollingOptions<T>) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const currentInterval = useRef(interval);
const isVisible = useRef(true);
const inflight = useRef(false);
const lastDataHash = useRef<string | null>(null);
const poll = useCallback(async () => {
// Dedupe: don't start new request if one is in flight
if (dedupe && inflight.current) return;
// Pause when tab is hidden
if (pauseWhenHidden && !isVisible.current) return;
inflight.current = true;
try {
const result = await fetcher();
// Only update state if data actually changed
const hash = JSON.stringify(result);
if (hash !== lastDataHash.current) {
lastDataHash.current = hash;
setData(result);
}
setError(null);
// Reset interval on success
currentInterval.current = interval;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
setError(err);
onError?.(err);
// Exponential backoff on error
if (exponentialBackoff) {
currentInterval.current = Math.min(
currentInterval.current * 2,
maxBackoff
);
}
} finally {
inflight.current = false;
setIsLoading(false);
}
}, [fetcher, interval, onError, pauseWhenHidden, exponentialBackoff, maxBackoff, dedupe]);
useEffect(() => {
if (!enabled) return;
// Initial fetch
poll();
// Set up polling
const id = setInterval(poll, currentInterval.current);
// Visibility change handler
const handleVisibility = () => {
isVisible.current = document.visibilityState === 'visible';
if (isVisible.current) {
poll(); // Immediate poll when tab becomes visible
}
};
if (pauseWhenHidden) {
document.addEventListener('visibilitychange', handleVisibility);
}
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [enabled, poll, pauseWhenHidden]);
return { data, error, isLoading, refetch: poll };
}
// Usage
function DashboardStats() {
const { data: stats, isLoading } = usePolling({
fetcher: () => fetch('/api/stats').then(r => r.json()),
interval: 30000, // 30 seconds
pauseWhenHidden: true,
exponentialBackoff: true,
});
if (isLoading) return <StatsLoading />;
return <StatsDisplay stats={stats} />;
}
Polling with React Query
// Using TanStack Query's built-in polling
import { useQuery } from '@tanstack/react-query';
function LiveDashboard() {
const { data, dataUpdatedAt } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
// Polling configuration
refetchInterval: 30000, // Poll every 30s
refetchIntervalInBackground: false, // Pause when tab hidden
// Stale-while-revalidate
staleTime: 25000, // Consider fresh for 25s
});
return (
<div>
<Dashboard stats={data} />
<LastUpdated timestamp={dataUpdatedAt} />
</div>
);
}
// Smart polling that adjusts based on data
function AdaptivePolling() {
const { data } = useQuery({
queryKey: ['orders'],
queryFn: fetchOrders,
refetchInterval: (query) => {
// Poll faster during business hours
const hour = new Date().getHours();
const isBusinessHours = hour >= 9 && hour < 17;
// Poll faster if there are pending orders
const hasPending = query.state.data?.some(o => o.status === 'pending');
if (hasPending) return 5000; // 5s when orders pending
if (isBusinessHours) return 30000; // 30s during business
return 120000; // 2min otherwise
},
});
return <OrderList orders={data} />;
}
Server-Sent Events (SSE): The Underrated Champion
SSE is often overlooked, but it's the best choice for most "server pushes data to client" scenarios. It's simpler than WebSockets, works over HTTP, and automatically reconnects.
Basic SSE Setup
// app/api/events/route.ts (Next.js App Router)
export const runtime = 'nodejs'; // SSE requires Node runtime
export async function GET(request: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Send initial connection message
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
);
// Subscribe to your event source (Redis, Postgres NOTIFY, etc.)
const subscription = await subscribeToEvents(async (event) => {
const data = `data: ${JSON.stringify(event)}\n\n`;
controller.enqueue(encoder.encode(data));
});
// Handle client disconnect
request.signal.addEventListener('abort', () => {
subscription.unsubscribe();
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
},
});
}
Production SSE with Event Types
// app/api/events/[userId]/route.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
interface SSEEvent {
id?: string;
event?: string; // Event type
data: unknown;
retry?: number;
}
function formatSSE(event: SSEEvent): string {
let message = '';
if (event.id) message += `id: ${event.id}\n`;
if (event.event) message += `event: ${event.event}\n`;
if (event.retry) message += `retry: ${event.retry}\n`;
message += `data: ${JSON.stringify(event.data)}\n\n`;
return message;
}
export async function GET(
request: Request,
{ params }: { params: { userId: string } }
) {
const { userId } = params;
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: SSEEvent) => {
controller.enqueue(encoder.encode(formatSSE(event)));
};
// Send retry interval suggestion
send({ event: 'init', data: { connected: true }, retry: 3000 });
// Subscribe to user-specific channel
const subscriber = redis.duplicate();
await subscriber.subscribe(`user:${userId}:events`);
subscriber.on('message', (channel, message) => {
const event = JSON.parse(message);
send({
id: event.id,
event: event.type,
data: event.payload,
});
});
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
send({ event: 'heartbeat', data: { timestamp: Date.now() } });
}, 30000);
// Cleanup on disconnect
request.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
subscriber.unsubscribe();
subscriber.quit();
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
},
});
}
React SSE Hook
// lib/hooks/use-event-source.ts
import { useEffect, useCallback, useRef, useState } from 'react';
interface UseEventSourceOptions {
url: string;
onMessage?: (event: MessageEvent) => void;
onError?: (error: Event) => void;
onOpen?: () => void;
eventHandlers?: Record<string, (data: any) => void>;
withCredentials?: boolean;
enabled?: boolean;
}
interface UseEventSourceReturn {
status: 'connecting' | 'connected' | 'disconnected' | 'error';
lastEventId: string | null;
reconnectCount: number;
}
export function useEventSource({
url,
onMessage,
onError,
onOpen,
eventHandlers = {},
withCredentials = true,
enabled = true,
}: UseEventSourceOptions): UseEventSourceReturn {
const [status, setStatus] = useState<UseEventSourceReturn['status']>('connecting');
const [lastEventId, setLastEventId] = useState<string | null>(null);
const [reconnectCount, setReconnectCount] = useState(0);
const eventSourceRef = useRef<EventSource | null>(null);
const connect = useCallback(() => {
if (!enabled) return;
// Close existing connection
eventSourceRef.current?.close();
// Build URL with last event ID for resumption
const connectUrl = lastEventId
? `${url}${url.includes('?') ? '&' : '?'}lastEventId=${lastEventId}`
: url;
const eventSource = new EventSource(connectUrl, { withCredentials });
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setStatus('connected');
setReconnectCount(0);
onOpen?.();
};
eventSource.onerror = (error) => {
setStatus('error');
onError?.(error);
// EventSource automatically reconnects, track attempts
setReconnectCount(c => c + 1);
};
eventSource.onmessage = (event) => {
if (event.lastEventId) {
setLastEventId(event.lastEventId);
}
onMessage?.(event);
};
// Register typed event handlers
Object.entries(eventHandlers).forEach(([eventType, handler]) => {
eventSource.addEventListener(eventType, (event: MessageEvent) => {
if (event.lastEventId) {
setLastEventId(event.lastEventId);
}
try {
const data = JSON.parse(event.data);
handler(data);
} catch {
handler(event.data);
}
});
});
return () => {
eventSource.close();
setStatus('disconnected');
};
}, [url, enabled, lastEventId, onMessage, onError, onOpen, eventHandlers, withCredentials]);
useEffect(() => {
const cleanup = connect();
return cleanup;
}, [connect]);
return { status, lastEventId, reconnectCount };
}
// Usage with typed events
function NotificationCenter() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const { status } = useEventSource({
url: '/api/events/notifications',
eventHandlers: {
notification: (data: Notification) => {
setNotifications(prev => [data, ...prev]);
showToast(data.message);
},
'notification-read': (data: { id: string }) => {
setNotifications(prev =>
prev.map(n => n.id === data.id ? { ...n, read: true } : n)
);
},
'notification-clear': () => {
setNotifications([]);
},
},
});
return (
<div>
<ConnectionStatus status={status} />
<NotificationList notifications={notifications} />
</div>
);
}
Publishing Events (Server Side)
// lib/events/publisher.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
interface EventPayload {
id: string;
type: string;
payload: unknown;
timestamp: number;
}
export async function publishToUser(
userId: string,
type: string,
payload: unknown
): Promise<void> {
const event: EventPayload = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
type,
payload,
timestamp: Date.now(),
};
await redis.publish(`user:${userId}:events`, JSON.stringify(event));
}
export async function publishToBroadcast(
channel: string,
type: string,
payload: unknown
): Promise<void> {
const event: EventPayload = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
type,
payload,
timestamp: Date.now(),
};
await redis.publish(`broadcast:${channel}`, JSON.stringify(event));
}
// Usage in your API routes or server actions
// app/api/orders/[id]/complete/route.ts
export async function POST(request: Request, { params }: { params: { id: string } }) {
const order = await completeOrder(params.id);
// Notify the customer
await publishToUser(order.customerId, 'order-completed', {
orderId: order.id,
total: order.total,
});
// Notify all dashboard viewers
await publishToBroadcast('orders', 'order-update', {
orderId: order.id,
status: 'completed',
});
return Response.json({ success: true });
}
WebSockets: When You Actually Need Them
WebSockets are more complex but necessary when you need true bidirectional real-time communication.
WebSocket Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ WEBSOCKET ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Client │────►│ Load Balancer │────►│ WS Server 1 │ │
│ │ Browser │◄────│ (sticky sessions) │◄────│ │ │
│ └─────────┘ └─────────────────────┘ │ ┌───────────────┐ │ │
│ │ │ │ Connections │ │ │
│ ┌─────────┐ │ │ │ Map │ │ │
│ │ Client │──────────────┤ │ └───────────────┘ │ │
│ │ Browser │◄─────────────┤ └─────────┬───────────┘ │
│ └─────────┘ │ │ │
│ │ │ Pub/Sub │
│ ┌─────────┐ │ │ │
│ │ Client │──────────────┘ ┌─────────▼───────────┐ │
│ │ Browser │◄────────────────────────────────►│ Redis │ │
│ └─────────┘ │ (Message Bus) │ │
│ └─────────┬───────────┘ │
│ │ │
│ ┌─────────▼───────────┐ │
│ │ WS Server 2 │ │
│ │ │ │
│ │ ┌───────────────┐ │ │
│ │ │ Connections │ │ │
│ │ │ Map │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
│ │
│ Key: Multiple WS servers share state via Redis pub/sub │
│ Sticky sessions ensure client reconnects to same server │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
WebSocket Server (Node.js)
// server/websocket.ts
import { WebSocketServer, WebSocket } from 'ws';
import { Redis } from 'ioredis';
import { IncomingMessage } from 'http';
import { parse } from 'url';
import jwt from 'jsonwebtoken';
const publisher = new Redis(process.env.REDIS_URL!);
const subscriber = new Redis(process.env.REDIS_URL!);
interface AuthenticatedWebSocket extends WebSocket {
userId: string;
isAlive: boolean;
rooms: Set<string>;
}
interface Message {
type: string;
payload: unknown;
room?: string;
}
class WebSocketManager {
private wss: WebSocketServer;
private connections: Map<string, Set<AuthenticatedWebSocket>> = new Map();
constructor(port: number) {
this.wss = new WebSocketServer({ port });
this.setupServer();
this.setupRedisSubscriber();
this.setupHeartbeat();
}
private async setupServer() {
this.wss.on('connection', async (ws: AuthenticatedWebSocket, req: IncomingMessage) => {
try {
// Authenticate
const { query } = parse(req.url || '', true);
const token = query.token as string;
const user = await this.authenticate(token);
ws.userId = user.id;
ws.isAlive = true;
ws.rooms = new Set();
// Track connection
this.addConnection(user.id, ws);
// Send connection confirmation
this.send(ws, { type: 'connected', payload: { userId: user.id } });
// Handle messages
ws.on('message', (data) => this.handleMessage(ws, data.toString()));
// Handle pong (heartbeat)
ws.on('pong', () => { ws.isAlive = true; });
// Handle disconnect
ws.on('close', () => this.handleDisconnect(ws));
} catch (error) {
ws.close(4001, 'Authentication failed');
}
});
}
private async authenticate(token: string): Promise<{ id: string }> {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
return { id: decoded.userId };
}
private addConnection(userId: string, ws: AuthenticatedWebSocket) {
if (!this.connections.has(userId)) {
this.connections.set(userId, new Set());
}
this.connections.get(userId)!.add(ws);
// Subscribe to user's personal channel
subscriber.subscribe(`user:${userId}`);
}
private handleDisconnect(ws: AuthenticatedWebSocket) {
const userConnections = this.connections.get(ws.userId);
if (userConnections) {
userConnections.delete(ws);
if (userConnections.size === 0) {
this.connections.delete(ws.userId);
subscriber.unsubscribe(`user:${ws.userId}`);
}
}
// Leave all rooms
ws.rooms.forEach(room => {
subscriber.unsubscribe(`room:${room}`);
});
}
private handleMessage(ws: AuthenticatedWebSocket, raw: string) {
try {
const message: Message = JSON.parse(raw);
switch (message.type) {
case 'join-room':
this.joinRoom(ws, message.payload as string);
break;
case 'leave-room':
this.leaveRoom(ws, message.payload as string);
break;
case 'room-message':
this.broadcastToRoom(
message.room!,
message.type,
message.payload,
ws.userId
);
break;
case 'direct-message':
this.sendToUser(
(message.payload as { to: string }).to,
message.type,
message.payload
);
break;
default:
// Handle other message types
this.handleCustomMessage(ws, message);
}
} catch (error) {
this.send(ws, { type: 'error', payload: { message: 'Invalid message format' } });
}
}
private joinRoom(ws: AuthenticatedWebSocket, room: string) {
ws.rooms.add(room);
subscriber.subscribe(`room:${room}`);
this.send(ws, { type: 'joined-room', payload: { room } });
}
private leaveRoom(ws: AuthenticatedWebSocket, room: string) {
ws.rooms.delete(room);
// Only unsubscribe if no other connections are in the room
const stillInRoom = Array.from(this.connections.values())
.some(conns => Array.from(conns).some(c => c.rooms.has(room)));
if (!stillInRoom) {
subscriber.unsubscribe(`room:${room}`);
}
this.send(ws, { type: 'left-room', payload: { room } });
}
private setupRedisSubscriber() {
subscriber.on('message', (channel, message) => {
const event = JSON.parse(message);
if (channel.startsWith('user:')) {
const userId = channel.replace('user:', '');
this.sendToUserLocal(userId, event.type, event.payload);
} else if (channel.startsWith('room:')) {
const room = channel.replace('room:', '');
this.broadcastToRoomLocal(room, event.type, event.payload);
}
});
}
private setupHeartbeat() {
setInterval(() => {
this.wss.clients.forEach((ws: AuthenticatedWebSocket) => {
if (!ws.isAlive) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
}
// Send to specific connection
private send(ws: WebSocket, message: Message) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
// Send to user on this server
private sendToUserLocal(userId: string, type: string, payload: unknown) {
const connections = this.connections.get(userId);
if (connections) {
connections.forEach(ws => this.send(ws, { type, payload }));
}
}
// Broadcast to room on this server
private broadcastToRoomLocal(room: string, type: string, payload: unknown) {
this.connections.forEach((connections) => {
connections.forEach(ws => {
if (ws.rooms.has(room)) {
this.send(ws, { type, payload });
}
});
});
}
// Public: Send to user (across all servers via Redis)
async sendToUser(userId: string, type: string, payload: unknown) {
await publisher.publish(
`user:${userId}`,
JSON.stringify({ type, payload })
);
}
// Public: Broadcast to room (across all servers via Redis)
async broadcastToRoom(
room: string,
type: string,
payload: unknown,
excludeUserId?: string
) {
await publisher.publish(
`room:${room}`,
JSON.stringify({ type, payload, excludeUserId })
);
}
private handleCustomMessage(ws: AuthenticatedWebSocket, message: Message) {
// Override in subclass or add handlers
}
}
export const wsManager = new WebSocketManager(Number(process.env.WS_PORT) || 3001);
React WebSocket Hook
// lib/hooks/use-websocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
interface UseWebSocketOptions {
url: string;
token: string;
onMessage?: (type: string, payload: unknown) => void;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: Event) => void;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
interface UseWebSocketReturn {
status: 'connecting' | 'connected' | 'disconnected' | 'error';
send: (type: string, payload: unknown, room?: string) => void;
joinRoom: (room: string) => void;
leaveRoom: (room: string) => void;
reconnectAttempts: number;
}
export function useWebSocket({
url,
token,
onMessage,
onConnect,
onDisconnect,
onError,
reconnect = true,
reconnectInterval = 3000,
maxReconnectAttempts = 10,
}: UseWebSocketOptions): UseWebSocketReturn {
const [status, setStatus] = useState<UseWebSocketReturn['status']>('connecting');
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const connect = useCallback(() => {
const wsUrl = `${url}?token=${token}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
setReconnectAttempts(0);
onConnect?.();
};
ws.onmessage = (event) => {
try {
const { type, payload } = JSON.parse(event.data);
onMessage?.(type, payload);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.onerror = (error) => {
setStatus('error');
onError?.(error);
};
ws.onclose = () => {
setStatus('disconnected');
onDisconnect?.();
// Reconnect logic
if (reconnect && reconnectAttempts < maxReconnectAttempts) {
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts(a => a + 1);
connect();
}, reconnectInterval * Math.min(reconnectAttempts + 1, 5)); // Backoff
}
};
}, [url, token, onMessage, onConnect, onDisconnect, onError, reconnect, reconnectInterval, maxReconnectAttempts, reconnectAttempts]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimeoutRef.current);
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((type: string, payload: unknown, room?: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type, payload, room }));
}
}, []);
const joinRoom = useCallback((room: string) => {
send('join-room', room);
}, [send]);
const leaveRoom = useCallback((room: string) => {
send('leave-room', room);
}, [send]);
return { status, send, joinRoom, leaveRoom, reconnectAttempts };
}
Chat Room Example
// components/chat-room.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from '@/lib/hooks/use-websocket';
interface Message {
id: string;
userId: string;
userName: string;
content: string;
timestamp: number;
}
export function ChatRoom({ roomId, userId, token }: {
roomId: string;
userId: string;
token: string;
}) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { status, send, joinRoom, leaveRoom } = useWebSocket({
url: process.env.NEXT_PUBLIC_WS_URL!,
token,
onMessage: (type, payload) => {
switch (type) {
case 'chat-message':
setMessages(prev => [...prev, payload as Message]);
break;
case 'user-typing':
const { userName, isTyping } = payload as { userName: string; isTyping: boolean };
setTypingUsers(prev =>
isTyping
? [...prev.filter(u => u !== userName), userName]
: prev.filter(u => u !== userName)
);
break;
case 'message-history':
setMessages(payload as Message[]);
break;
}
},
onConnect: () => {
joinRoom(roomId);
},
});
// Cleanup on unmount
useEffect(() => {
return () => {
leaveRoom(roomId);
};
}, [roomId, leaveRoom]);
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Typing indicator
const handleTyping = () => {
send('room-message', { userName: 'You', isTyping: true }, roomId);
// Clear typing after 2 seconds of no input
const timeout = setTimeout(() => {
send('room-message', { userName: 'You', isTyping: false }, roomId);
}, 2000);
return () => clearTimeout(timeout);
};
const sendMessage = () => {
if (!input.trim()) return;
send('room-message', {
type: 'chat-message',
content: input,
userId,
}, roomId);
setInput('');
};
return (
<div className="flex flex-col h-screen">
<ConnectionIndicator status={status} />
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.map(message => (
<MessageBubble
key={message.id}
message={message}
isOwn={message.userId === userId}
/>
))}
<div ref={messagesEndRef} />
</div>
{typingUsers.length > 0 && (
<div className="px-4 py-2 text-sm text-gray-500">
{typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
</div>
)}
<div className="p-4 border-t">
<div className="flex gap-2">
<input
value={input}
onChange={(e) => {
setInput(e.target.value);
handleTyping();
}}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border rounded"
/>
<button
onClick={sendMessage}
disabled={status !== 'connected'}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
);
}
Hybrid Patterns: Combining Approaches
SSE for Push + REST for Commands
// The hybrid pattern: SSE for server→client, REST for client→server
// Often simpler than WebSockets for many use cases
// lib/hooks/use-hybrid-realtime.ts
import { useEventSource } from './use-event-source';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useCollaborativeDocument(documentId: string) {
const queryClient = useQueryClient();
// Receive updates via SSE
const { status } = useEventSource({
url: `/api/documents/${documentId}/events`,
eventHandlers: {
'content-update': (data) => {
queryClient.setQueryData(['document', documentId], (old: Document) => ({
...old,
content: data.content,
version: data.version,
}));
},
'cursor-move': (data) => {
queryClient.setQueryData(['cursors', documentId], (old: Cursor[]) => {
return old
.filter(c => c.userId !== data.userId)
.concat(data);
});
},
'user-joined': (data) => {
queryClient.setQueryData(['users', documentId], (old: User[]) => [...old, data]);
},
'user-left': (data) => {
queryClient.setQueryData(['users', documentId], (old: User[]) =>
old.filter(u => u.id !== data.userId)
);
},
},
});
// Send updates via REST
const updateContent = useMutation({
mutationFn: async (content: string) => {
const response = await fetch(`/api/documents/${documentId}/content`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
return response.json();
},
});
const updateCursor = useMutation({
mutationFn: async (position: { line: number; column: number }) => {
await fetch(`/api/documents/${documentId}/cursor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(position),
});
},
});
return {
status,
updateContent: updateContent.mutate,
updateCursor: updateCursor.mutate,
isUpdating: updateContent.isPending,
};
}
Polling Fallback for Unreliable Connections
// lib/hooks/use-resilient-realtime.ts
// Falls back to polling if SSE fails multiple times
import { useEventSource } from './use-event-source';
import { usePolling } from './use-polling';
import { useState, useEffect } from 'react';
export function useResilientRealtime<T>({
sseUrl,
pollingUrl,
pollingInterval = 30000,
maxSSERetries = 3,
onData,
}: {
sseUrl: string;
pollingUrl: string;
pollingInterval?: number;
maxSSERetries?: number;
onData: (data: T) => void;
}) {
const [mode, setMode] = useState<'sse' | 'polling'>('sse');
const [sseRetries, setSSERetries] = useState(0);
// SSE connection
const { status: sseStatus, reconnectCount } = useEventSource({
url: sseUrl,
enabled: mode === 'sse',
eventHandlers: {
update: onData,
},
});
// Track SSE failures
useEffect(() => {
if (sseStatus === 'error') {
setSSERetries(r => r + 1);
}
}, [sseStatus]);
// Fall back to polling after max retries
useEffect(() => {
if (sseRetries >= maxSSERetries) {
console.warn('SSE failed, falling back to polling');
setMode('polling');
}
}, [sseRetries, maxSSERetries]);
// Polling fallback
const { data: pollingData } = usePolling({
fetcher: () => fetch(pollingUrl).then(r => r.json()),
interval: pollingInterval,
enabled: mode === 'polling',
});
useEffect(() => {
if (mode === 'polling' && pollingData) {
onData(pollingData);
}
}, [mode, pollingData, onData]);
// Periodically try to switch back to SSE
useEffect(() => {
if (mode === 'polling') {
const retrySSE = setInterval(() => {
console.log('Retrying SSE connection...');
setSSERetries(0);
setMode('sse');
}, 60000); // Try SSE again every minute
return () => clearInterval(retrySSE);
}
}, [mode]);
return {
mode,
status: mode === 'sse' ? sseStatus : 'polling',
reconnectCount: mode === 'sse' ? reconnectCount : 0,
};
}
Production Considerations
Scaling WebSockets
┌─────────────────────────────────────────────────────────────────────────────┐
│ WEBSOCKET SCALING PATTERNS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Challenge: Each WebSocket connection is stateful, tied to one server │
│ │
│ Solution 1: Sticky Sessions + Pub/Sub │
│ ───────────────────────────────────── │
│ • Load balancer routes same client to same server (IP hash, cookie) │
│ • Redis pub/sub syncs messages across servers │
│ • Simple, works well up to ~100K connections per server │
│ │
│ Solution 2: Dedicated WebSocket Service │
│ ────────────────────────────────────── │
│ • Separate WS servers from API servers │
│ • API servers publish to message bus │
│ • WS servers subscribe and broadcast │
│ • Scale WS and API independently │
│ │
│ Solution 3: Managed Service │
│ ──────────────────────────── │
│ • Pusher, Ably, AWS API Gateway WebSocket │
│ • Offload scaling complexity │
│ • Cost per connection/message │
│ │
│ Connection Limits (per server): │
│ • Node.js: ~50K-100K connections (memory dependent) │
│ • File descriptors: `ulimit -n` (default often 1024, increase to 65535) │
│ • Memory: ~10-50KB per connection │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Authentication and Security
// Secure WebSocket authentication patterns
// Option 1: Token in query string (simple, but visible in logs)
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);
// Option 2: Authenticate via HTTP first, then connect
async function secureConnect() {
// 1. Get short-lived WS ticket via authenticated HTTP request
const response = await fetch('/api/ws/ticket', {
method: 'POST',
credentials: 'include', // Send cookies
});
const { ticket } = await response.json();
// 2. Connect with ticket (ticket valid for ~30 seconds, single use)
const ws = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`);
return ws;
}
// Server-side ticket validation
const tickets = new Map<string, { userId: string; expires: number }>();
app.post('/api/ws/ticket', authenticate, (req, res) => {
const ticket = crypto.randomUUID();
tickets.set(ticket, {
userId: req.user.id,
expires: Date.now() + 30000, // 30 seconds
});
res.json({ ticket });
});
wsServer.on('connection', (ws, req) => {
const ticket = new URL(req.url, 'http://localhost').searchParams.get('ticket');
const ticketData = tickets.get(ticket);
if (!ticketData || ticketData.expires < Date.now()) {
ws.close(4001, 'Invalid or expired ticket');
return;
}
// Single use - delete ticket
tickets.delete(ticket);
// Authenticated!
ws.userId = ticketData.userId;
});
Error Handling and Reconnection
// Comprehensive reconnection strategy
class ResilientWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private messageQueue: string[] = [];
private isIntentionallyClosed = false;
constructor(
private url: string,
private options: {
onMessage: (data: unknown) => void;
onStatusChange: (status: string) => void;
getToken: () => Promise<string>;
}
) {}
async connect() {
this.isIntentionallyClosed = false;
const token = await this.options.getToken();
this.ws = new WebSocket(`${this.url}?token=${token}`);
this.ws.onopen = () => {
this.options.onStatusChange('connected');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
// Flush queued messages
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift()!;
this.ws?.send(message);
}
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.options.onMessage(data);
} catch (e) {
console.error('Failed to parse message:', e);
}
};
this.ws.onclose = (event) => {
if (this.isIntentionallyClosed) {
this.options.onStatusChange('disconnected');
return;
}
// Determine if we should reconnect
if (event.code === 4001) {
// Auth error - don't reconnect, need new token
this.options.onStatusChange('auth-error');
return;
}
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.options.onStatusChange('reconnecting');
this.scheduleReconnect();
} else {
this.options.onStatusChange('failed');
}
};
this.ws.onerror = () => {
// onerror is always followed by onclose, so handle reconnect there
};
}
private scheduleReconnect() {
const delay = Math.min(
this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
);
// Add jitter to prevent thundering herd
const jitter = delay * 0.2 * Math.random();
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay + jitter);
}
send(data: unknown) {
const message = JSON.stringify(data);
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
// Queue for later
this.messageQueue.push(message);
// Limit queue size
if (this.messageQueue.length > 100) {
this.messageQueue.shift();
}
}
}
disconnect() {
this.isIntentionallyClosed = true;
this.ws?.close();
}
}
Quick Reference
When to Use What
┌─────────────────────────────────────────────────────────────────────────────┐
│ REAL-TIME STRATEGY QUICK REFERENCE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Use Case Recommendation │
│ ──────────────────────────────────────────────────────────────────── │
│ Dashboard updated every 30s+ Polling (simple, sufficient) │
│ Notifications SSE (server push, auto-reconnect) │
│ Activity feed SSE (ordered events, one direction) │
│ Live sports scores SSE (frequent updates, read-only) │
│ Stock ticker (viewing) SSE (high frequency, read-only) │
│ Stock ticker (trading) WebSocket (bidirectional, low latency) │
│ Chat WebSocket (bidirectional messaging) │
│ Collaborative editing WebSocket + CRDT (conflict resolution) │
│ Multiplayer game WebSocket (low latency required) │
│ Presence (who's online) WebSocket (frequent status updates) │
│ File upload progress SSE or polling (simple progress) │
│ Form autosave Debounced REST (no real-time needed) │
│ │
│ Infrastructure Available Consider │
│ ──────────────────────────────────────────────────────────────────── │
│ Just HTTP (serverless) Polling, short-polling │
│ HTTP + Redis SSE (recommended for most) │
│ Full infra control WebSocket when needed │
│ Don't want to manage Pusher, Ably, Supabase Realtime │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Checklist Before Going Live
## Real-Time Production Checklist
### Connection Management
□ Automatic reconnection with exponential backoff
□ Reconnection jitter to prevent thundering herd
□ Connection status indicator in UI
□ Graceful degradation when disconnected
□ Message queuing during disconnection
### Authentication
□ Tokens validated on connection
□ Token refresh handling
□ Connection closed on auth failure
□ Secure WebSocket (wss://) in production
### Scalability
□ Pub/sub for multi-server deployment
□ Sticky sessions configured (if needed)
□ Connection limits understood and documented
□ Load testing performed
### Reliability
□ Heartbeat/ping-pong implemented
□ Dead connection detection
□ Message ordering guaranteed (if needed)
□ Duplicate message handling
### Monitoring
□ Connection count metrics
□ Message throughput metrics
□ Error rate tracking
□ Latency percentiles
□ Reconnection rate
### Security
□ Rate limiting on connections
□ Rate limiting on messages
□ Input validation on all messages
□ Room/channel authorization
Closing Thoughts
Real-time features aren't one-size-fits-all. The right choice depends on your specific requirements:
- Polling isn't dead—it's often the simplest solution when data doesn't need to be truly live
- SSE is underrated—it handles most "server pushes updates" scenarios with less complexity than WebSockets
- WebSockets are powerful but bring operational complexity—use them when you genuinely need bidirectional real-time communication
The pattern I've seen work best for most applications:
- Start with React Query + polling for data that updates infrequently
- Add SSE for notifications and live feeds
- Introduce WebSockets only for truly interactive features (chat, collaboration)
- Consider managed services (Pusher, Ably) if you don't want to manage WebSocket infrastructure
The biggest mistake isn't choosing the "wrong" technology—it's over-engineering from the start. A well-implemented polling solution is better than a poorly-implemented WebSocket solution. Start simple, measure, and add complexity only when the simpler solution genuinely can't meet your requirements.
Real-time features should feel magical to users, not to the engineers maintaining them. Choose the simplest approach that meets your actual requirements.
What did you think?
Related Posts
March 23, 2026132 min
Backend Stream Processing Internals: Windowing, Exactly-Once Semantics, Backpressure & Stateful Operators
March 22, 202647 min
Background Sync & Push API Internals: Service Worker Push, Periodic Sync, and Offline-First Patterns
March 5, 20263 min