Real-time features look straightforward in demos but require careful thinking about connection lifecycle, reconnection, state synchronization, and scalability. WebSocket servers, presence tracking, broadcast patterns, and conflict resolution all have non-obvious implementation details. Claude Code handles real-time development well because it generates these patterns correctly — including the edge cases like reconnection, stale state, and multi-server pub/sub.
This guide covers real-time development with Claude Code: WebSocket servers, Socket.IO, Server-Sent Events, presence systems, and collaborative features.
Choosing the Right Transport
Before implementation, Claude helps with the technology choice:
I need real-time updates for:
1. A live order tracking page (user sees their delivery status)
2. A collaborative document editor
3. A live analytics dashboard
What transport should I use for each?
Claude provides concrete guidance:
- Order tracking: Server-Sent Events (SSE) — server-only updates, simpler than WebSocket, automatic reconnection, HTTP/2 multiplexing
- Document editor: WebSocket — bidirectional, low-latency, needed for operational transforms/CRDTs
- Analytics dashboard: SSE or WebSocket depending on update frequency — SSE for < 5s intervals, WebSocket if you need sub-second
Server-Sent Events (SSE)
SSE is the right choice for server-to-client updates. Claude generates the full implementation:
Build an SSE endpoint that streams live order status updates.
Client connects, server sends events as the order progresses.
Handle client disconnection.
// Express SSE endpoint
app.get('/api/orders/:id/stream', requireAuth, async (req, res) => {
const orderId = req.params.id;
// Verify order belongs to requesting user
const order = await db.order.findFirst({
where: { id: orderId, userId: req.user.id },
});
if (!order) {
res.status(404).json({ error: 'Order not found' });
return;
}
// SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
res.flushHeaders();
// Send initial state
res.write(`data: ${JSON.stringify({ status: order.status, updatedAt: order.updatedAt })}\n\n`);
// Subscribe to order updates
const unsubscribe = orderEvents.on(`order:${orderId}`, (update) => {
res.write(`event: status_update\n`);
res.write(`data: ${JSON.stringify(update)}\n\n`);
// Close stream on terminal states
if (['delivered', 'cancelled'].includes(update.status)) {
res.write(`event: complete\ndata: {}\n\n`);
res.end();
}
});
// Cleanup on client disconnect
req.on('close', () => {
unsubscribe();
});
// Heartbeat to prevent proxy timeouts
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15000);
req.on('close', () => clearInterval(heartbeat));
});
Claude includes: X-Accel-Buffering: no for nginx (otherwise nginx buffers SSE and events arrive late), the heartbeat to keep connections alive through proxies, and cleanup on disconnect to prevent memory leaks.
SSE Client (React)
Write a React hook for connecting to an SSE stream.
Auto-reconnect on connection loss. Cleanup on unmount.
import { useState, useEffect, useRef } from 'react';
interface UseSSEOptions {
onMessage?: (event: MessageEvent) => void;
onError?: (error: Event) => void;
eventTypes?: string[];
}
export function useSSE(url: string | null, options: UseSSEOptions = {}) {
const [connected, setConnected] = useState(false);
const esRef = useRef<EventSource | null>(null);
const reconnectTimeout = useRef<NodeJS.Timeout>();
useEffect(() => {
if (!url) return;
let retryDelay = 1000;
function connect() {
const es = new EventSource(url!);
esRef.current = es;
es.onopen = () => {
setConnected(true);
retryDelay = 1000; // Reset backoff on successful connection
};
es.onerror = (error) => {
options.onError?.(error);
setConnected(false);
es.close();
// Exponential backoff reconnect, capped at 30s
reconnectTimeout.current = setTimeout(() => {
retryDelay = Math.min(retryDelay * 2, 30000);
connect();
}, retryDelay);
};
if (options.eventTypes) {
options.eventTypes.forEach(type => {
es.addEventListener(type, options.onMessage as EventListener);
});
} else {
es.onmessage = options.onMessage ?? null;
}
}
connect();
return () => {
esRef.current?.close();
clearTimeout(reconnectTimeout.current);
};
}, [url]);
return { connected };
}
WebSocket Server
Raw WebSocket with ws
Build a WebSocket server for a live chat room.
Multiple rooms. Messages broadcast to all room members.
Show who's currently online in each room.
import { WebSocketServer, WebSocket } from 'ws';
import { Server } from 'http';
interface Client {
ws: WebSocket;
userId: string;
username: string;
roomId: string;
}
const clients = new Map<WebSocket, Client>();
const rooms = new Map<string, Set<WebSocket>>();
export function setupWebSocket(server: Server) {
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws, req) => {
// Auth from query param (token passed in ws://host/ws?token=xxx)
const token = new URL(req.url!, 'ws://base').searchParams.get('token');
const user = verifyToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleMessage(ws, clients.get(ws)!, message);
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
}
});
ws.on('close', () => {
const client = clients.get(ws);
if (client) {
leaveRoom(ws, client);
clients.delete(ws);
}
});
ws.on('error', (err) => {
console.error('WebSocket error:', err);
});
});
}
function handleMessage(ws: WebSocket, client: Client, message: any) {
switch (message.type) {
case 'join_room':
joinRoom(ws, client, message.roomId);
break;
case 'chat_message':
if (!client.roomId) return;
broadcastToRoom(client.roomId, {
type: 'chat_message',
userId: client.userId,
username: client.username,
text: message.text,
timestamp: Date.now(),
}, ws); // Don't echo back to sender
break;
}
}
function broadcastToRoom(roomId: string, message: object, exclude?: WebSocket) {
const room = rooms.get(roomId);
if (!room) return;
const json = JSON.stringify(message);
for (const clientWs of room) {
if (clientWs !== exclude && clientWs.readyState === WebSocket.OPEN) {
clientWs.send(json);
}
}
}
Claude includes: the .readyState === WebSocket.OPEN check before sending (always required — connections can close between iterations), error handling in the message parser, and connection cleanup.
Socket.IO for Managed Connections
Use Socket.IO instead of raw WebSocket.
Add: reconnection, room management, and acknowledgments for
important messages that must be delivered.
import { Server } from 'socket.io';
import type { Server as HttpServer } from 'http';
export function setupSocketIO(httpServer: HttpServer) {
const io = new Server(httpServer, {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
// Redis adapter for multi-server (install separately):
// adapter: createAdapter(pubClient, subClient),
});
io.use((socket, next) => {
const token = socket.handshake.auth.token;
const user = verifyToken(token);
if (!user) return next(new Error('Unauthorized'));
socket.data.user = user;
next();
});
io.on('connection', (socket) => {
socket.on('join_room', async (roomId: string, callback) => {
await socket.join(roomId);
socket.to(roomId).emit('user_joined', { userId: socket.data.user.id });
// Acknowledgment: client knows the join succeeded
callback({ success: true, onlineCount: (await io.in(roomId).fetchSockets()).length });
});
socket.on('send_message', (data, callback) => {
// Validate
if (!data.text || typeof data.text !== 'string') {
return callback({ error: 'Invalid message' });
}
io.to(data.roomId).emit('new_message', {
id: generateId(),
userId: socket.data.user.id,
text: data.text.slice(0, 2000), // Limit length
timestamp: Date.now(),
});
callback({ success: true }); // Acknowledge delivery
});
socket.on('disconnecting', () => {
// socket.rooms includes the socket's own room + joined rooms
for (const roomId of socket.rooms) {
if (roomId !== socket.id) {
socket.to(roomId).emit('user_left', { userId: socket.data.user.id });
}
}
});
});
}
Socket.IO acknowledgments (callback({ success: true })) are important for actions where clients need confirmation — without them, the client has no way to know if the server received the message.
Presence System
Build presence tracking: who's online, who's viewing which document,
show "user is typing" indicator.
// Server-side presence state
const presence = new Map<string, {
userId: string;
documentId: string;
lastSeen: number;
isTyping: boolean;
}>();
// Typing indicator with debounced timeout
const typingTimeouts = new Map<string, NodeJS.Timeout>();
socket.on('typing_start', ({ documentId }) => {
const key = `${socket.data.user.id}:${documentId}`;
presence.set(key, {
userId: socket.data.user.id,
documentId,
lastSeen: Date.now(),
isTyping: true,
});
socket.to(documentId).emit('user_typing', { userId: socket.data.user.id });
// Auto-clear typing indicator after 3s of inactivity
clearTimeout(typingTimeouts.get(key));
typingTimeouts.set(key, setTimeout(() => {
updateTypingState(key, false, documentId, socket);
}, 3000));
});
The 3-second timeout auto-clears the typing indicator — users often navigate away without explicitly stopping typing, so server-side cleanup is required.
Multi-Server Real-Time with Redis Pub/Sub
My WebSocket server needs to scale to multiple instances.
Messages from a user connected to server 1 must reach users on server 2.
Claude generates the Redis adapter pattern:
import { createClient } from 'redis';
import { createAdapter } from '@socket.io/redis-adapter';
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
With the Redis adapter, io.to(roomId).emit(...) automatically routes through Redis pub/sub to reach the correct server instance. Claude explains why this is needed (WebSocket connections are stateful, not shared between servers) and when to use it (as soon as you have >1 instance).
Testing Real-Time Features
Write tests for the WebSocket chat room.
Test: joining, messaging, disconnect behavior.
Claude writes tests using socket.io-client (for Socket.IO) or ws library (for raw WebSocket) in Jest, creating client connections in beforeEach and closing them in afterEach. It tests the full interaction: connect → join room → send message → verify broadcast → disconnect.
Real-Time Patterns with Claude Code
Real-time features have many edge cases that are easy to miss: what happens when a connection drops mid-message? What if two users update the same document simultaneously? Claude Code generates defensively — including cleanup handlers, connection state checks, and reconnection logic that most tutorials skip.
For the database layer that backs real-time features (persisting messages, tracking presence history), see the database guide. For deploying real-time apps in containers with proper networking, see the Docker guide. The Claude Skills 360 bundle includes real-time skill sets covering collaborative editing, live dashboards, and notification systems. Start with the free tier for the basic WebSocket and SSE patterns.