Real-Time Applications with Next.js and WebSockets: Building Live Collaboration Features
Master WebSocket integration in Next.js 15 — real-time chat, collaboration, notifications, and horizontal scaling strategies.
Real-Time Applications with Next.js and WebSockets: Building Live Collaboration Features
WebSockets transformed web development. Instead of polling for updates every second, servers can push changes the moment they happen. Real-time applications—collaborative editors, live chats, multiplayer games—create the most engaging user experiences because feedback is instant.
But WebSockets are complex at scale. Connection management, message broadcasting, handling disconnects gracefully, scaling across multiple servers—these are non-trivial problems. I've built several production real-time systems (including a Reflys feature for live collaboration), and I've learned the patterns that work and the architecture decisions that haunt you later.
Let me share the complete blueprint for building scalable, production-ready real-time systems with Next.js.
Understanding WebSockets vs Other Real-Time Protocols
Comparison: What Should You Use?
| Technology | Latency | Browser Support | Scalability | Use Case |
|---|---|---|---|---|
| WebSocket | <100ms | Native | Moderate (with Redis pub/sub) | Chat, collaboration, games |
| Server-Sent Events | 50-500ms | Native | Easy | Notifications, streaming data |
| gRPC-web | <50ms | Requires proxy | High | High-frequency trading, data |
| Polling | 1-5s | Native | Good | Legacy, simple updates |
| GraphQL Subscriptions | <100ms | With upgrading | Moderate | Typed real-time queries |
For most use cases, WebSocket is the best choice. It's the only truly bidirectional protocol that works across all browsers without compromise.
Architecture: Single-Server vs Distributed
Single Server Architecture (Development/MVP)
// server.ts - Simple WebSocket server
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import express from 'express';
const app = express();
const httpServer = createServer(app);
const io = new SocketIOServer(httpServer, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
},
});
interface UserConnection {
userId: string;
socketId: string;
room: string;
}
const userConnections = new Map<string, UserConnection>();
io.on('connection', (socket) => {
console.log(`User connected: ${socket.id}`);
// User joins a room (e.g., document collaboration)
socket.on('join-room', ({ userId, roomId }) => {
socket.join(roomId);
userConnections.set(socket.id, { userId, socketId: socket.id, room: roomId });
// Notify others in room
socket.to(roomId).emit('user-joined', {
userId,
totalUsers: io.sockets.adapter.rooms.get(roomId)?.size || 0,
});
});
// Real-time document updates
socket.on('document-update', ({ roomId, change }) => {
// Broadcast to everyone in room except sender
socket.to(roomId).emit('remote-change', {
userId: userConnections.get(socket.id)?.userId,
change,
timestamp: Date.now(),
});
});
// Cursor position for collaboration
socket.on('cursor-move', ({ roomId, position }) => {
socket.to(roomId).emit('remote-cursor', {
userId: userConnections.get(socket.id)?.userId,
position,
});
});
socket.on('disconnect', () => {
const user = userConnections.get(socket.id);
if (user) {
io.to(user.room).emit('user-left', { userId: user.userId });
userConnections.delete(socket.id);
}
});
});
httpServer.listen(3001, () => {
console.log('WebSocket server listening on port 3001');
});
When to use: Development, proof-of-concepts, <100 concurrent users
Distributed Architecture (Production)
For production, you need to handle multiple servers behind a load balancer. This requires Redis pub/sub for cross-server communication:
// server.ts - Production WebSocket with Redis adapter
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
import express from 'express';
const app = express();
const httpServer = createServer(app);
// Redis clients for pub/sub
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = new SocketIOServer(httpServer, {
adapter: createAdapter(pubClient, subClient), // Enable cross-server communication
cors: { origin: process.env.CLIENT_URL },
});
io.on('connection', (socket) => {
socket.on('join-room', ({ userId, roomId }) => {
socket.join(roomId);
// This works across multiple servers thanks to Redis adapter
socket.to(roomId).emit('user-joined', { userId });
});
socket.on('document-update', async ({ roomId, change }) => {
// Persist to database before broadcasting
await saveDocumentChange(roomId, change);
// Broadcast to all servers
io.to(roomId).emit('remote-change', {
change,
timestamp: Date.now(),
});
});
socket.on('disconnect', () => {
console.log(`User ${socket.id} disconnected`);
});
});
httpServer.listen(3001);
When to use: Production, 100+ concurrent users, horizontal scaling required
Next.js Integration Patterns
Pattern 1: Separate WebSocket Server with Next.js API Routes
// lib/socket-client.ts
import { io } from 'socket.io-client';
export const socket = io('ws://localhost:3001', {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
});
socket.on('connect_error', (error) => {
console.error('Connection error:', error);
});
socket.on('disconnect', (reason) => {
console.warn('Disconnected:', reason);
});
// hooks/useRealtimeDocument.ts
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { socket } from '@/lib/socket-client';
import { debounce } from '@/lib/utils';
interface DocumentChange {
userId: string;
position: number;
content: string;
timestamp: number;
}
export function useRealtimeDocument(roomId: string, userId: string) {
const [document, setDocument] = useState('');
const [remoteUsers, setRemoteUsers] = useState<string[]>([]);
const [remoteCursors, setRemoteCursors] = useState<
Record<string, { position: number; userId: string }>
>({});
const changeQueueRef = useRef<DocumentChange[]>([]);
// Join room on mount
useEffect(() => {
socket.emit('join-room', { userId, roomId });
return () => {
socket.emit('leave-room', roomId);
};
}, [roomId, userId]);
// Listen for remote changes
useEffect(() => {
const handleRemoteChange = (change: DocumentChange) => {
// Implement OT (Operational Transform) for conflict resolution
setDocument((prev) => applyChange(prev, change));
};
const handleUserJoined = (data: { userId: string; totalUsers: number }) => {
setRemoteUsers((prev) => [...prev, data.userId]);
};
const handleUserLeft = (data: { userId: string }) => {
setRemoteUsers((prev) => prev.filter((u) => u !== data.userId));
setRemoteCursors((prev) => {
const updated = { ...prev };
delete updated[data.userId];
return updated;
});
};
const handleRemoteCursor = (data: {
userId: string;
position: number;
}) => {
setRemoteCursors((prev) => ({
...prev,
[data.userId]: { position: data.position, userId: data.userId },
}));
};
socket.on('remote-change', handleRemoteChange);
socket.on('user-joined', handleUserJoined);
socket.on('user-left', handleUserLeft);
socket.on('remote-cursor', handleRemoteCursor);
return () => {
socket.off('remote-change', handleRemoteChange);
socket.off('user-joined', handleUserJoined);
socket.off('user-left', handleUserLeft);
socket.off('remote-cursor', handleRemoteCursor);
};
}, []);
// Debounced local changes to avoid flooding server
const handleDocumentChange = useCallback(
debounce((newContent: string, cursorPosition: number) => {
const change: DocumentChange = {
userId,
position: cursorPosition,
content: newContent,
timestamp: Date.now(),
};
setDocument(newContent);
socket.emit('document-update', { roomId, change });
socket.emit('cursor-move', { roomId, position: cursorPosition });
}, 100),
[roomId, userId]
);
return {
document,
handleDocumentChange,
remoteUsers,
remoteCursors,
};
}
// components/CollaborativeEditor.tsx
'use client';
import { useState } from 'react';
import { useRealtimeDocument } from '@/hooks/useRealtimeDocument';
import { RemoteCursor } from './RemoteCursor';
interface CollaborativeEditorProps {
roomId: string;
userId: string;
}
export function CollaborativeEditor({ roomId, userId }: CollaborativeEditorProps) {
const { document, handleDocumentChange, remoteUsers, remoteCursors } =
useRealtimeDocument(roomId, userId);
const [cursorPosition, setCursorPosition] = useState(0);
return (
<div className="grid grid-cols-4 gap-4 h-screen">
{/* Editor */}
<div className="col-span-3">
<div className="h-full flex flex-col border rounded-lg">
<div className="p-4 border-b bg-gray-50">
<h1 className="text-lg font-bold">Collaborative Document</h1>
<p className="text-sm text-gray-600">
{remoteUsers.length} other user(s) editing
</p>
</div>
<textarea
value={document}
onChange={(e) => {
setCursorPosition(e.target.selectionStart);
handleDocumentChange(e.target.value, e.target.selectionStart);
}}
className="flex-1 p-4 font-mono resize-none focus:outline-none"
placeholder="Start typing..."
/>
</div>
</div>
{/* Collaborators Sidebar */}
<div className="border rounded-lg p-4 space-y-3">
<h2 className="font-bold text-sm">Collaborators</h2>
<div className="space-y-2">
{remoteUsers.map((userId) => (
<div key={userId} className="text-sm">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>{userId}</span>
</div>
{remoteCursors[userId] && (
<div className="text-xs text-gray-500 ml-4">
Cursor: {remoteCursors[userId].position}
</div>
)}
</div>
))}
</div>
</div>
{/* Render remote cursors */}
{Object.values(remoteCursors).map((cursor) => (
<RemoteCursor
key={cursor.userId}
position={cursor.position}
userId={cursor.userId}
/>
))}
</div>
);
}
Pattern 2: Handling Disconnects Gracefully
// hooks/useRealtimeSync.ts
'use client';
import { useEffect, useState } from 'react';
import { socket } from '@/lib/socket-client';
export function useRealtimeSync(userId: string) {
const [isConnected, setIsConnected] = useState(socket.connected);
const [pendingChanges, setPendingChanges] = useState<any[]>([]);
useEffect(() => {
const onConnect = () => {
console.log('Connected to WebSocket server');
setIsConnected(true);
// Send any pending changes
pendingChanges.forEach((change) => {
socket.emit('sync-change', change);
});
setPendingChanges([]);
};
const onDisconnect = () => {
console.log('Disconnected from WebSocket server');
setIsConnected(false);
};
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
return () => {
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [pendingChanges]);
const sendChange = (change: any) => {
if (isConnected) {
socket.emit('change', change);
} else {
// Queue for later
setPendingChanges((prev) => [...prev, change]);
}
};
return {
isConnected,
sendChange,
pendingChanges: pendingChanges.length,
};
}
// components/ConnectionStatus.tsx
'use client';
import { useRealtimeSync } from '@/hooks/useRealtimeSync';
import { motion } from 'framer-motion';
export function ConnectionStatus({ userId }: { userId: string }) {
const { isConnected, pendingChanges } = useRealtimeSync(userId);
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`fixed top-4 right-4 px-4 py-2 rounded-lg text-sm font-medium ${
isConnected ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}
>
{isConnected ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Connected
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-bounce" />
Reconnecting... {pendingChanges > 0 && `(${pendingChanges} pending)`}
</div>
)}
</motion.div>
);
}
Operational Transform: Conflict Resolution
When multiple users edit simultaneously, conflicts happen. Operational Transform (OT) solves this:
// lib/operational-transform.ts
interface Operation {
type: 'insert' | 'delete';
position: number;
content?: string;
length?: number;
}
export function applyOperation(
content: string,
op: Operation
): string {
if (op.type === 'insert') {
return content.slice(0, op.position) + op.content + content.slice(op.position);
} else if (op.type === 'delete') {
return content.slice(0, op.position) + content.slice(op.position + op.length!);
}
return content;
}
export function transform(
op1: Operation,
op2: Operation
): Operation {
// Transform op1 against op2
// If both insert at same position, op1 comes first
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position <= op2.position) {
return op1;
} else {
return {
...op1,
position: op1.position + op2.content!.length,
};
}
}
if (op1.type === 'delete' && op2.type === 'insert') {
if (op1.position > op2.position) {
return {
...op1,
position: op1.position + op2.content!.length,
};
}
}
// Additional cases...
return op1;
}
Scaling: Rate Limiting and Broadcasting
// server.ts - Rate limiting and efficient broadcasting
import rateLimit from 'express-rate-limit';
const updateLimiter = rateLimit({
windowMs: 1000, // Per second
max: 100, // Max 100 updates per second per room
});
io.on('connection', (socket) => {
socket.on('document-update', ({ roomId, change }) => {
// Rate limit per user
const key = `${socket.id}:update`;
// Check rate limit...
// Batch updates for efficiency (don't emit every keystroke)
io.to(roomId).emit('batch-update', {
changes: [change],
timestamp: Date.now(),
});
});
// Limit max message size
socket.on('document-update', ({ change }) => {
if (JSON.stringify(change).length > 10000) {
socket.emit('error', { message: 'Update too large' });
return;
}
});
});
Common Pitfalls and Solutions
| Problem | Cause | Solution |
|---|---|---|
| Memory leak | Event listeners not cleaned up | Use useEffect cleanup in hooks |
| Message order | Multiple servers, different paths | Use event sequence numbers |
| Lost updates | Disconnect during send | Queue + retry on reconnect |
| Broadcast storm | Many clients, too many messages | Debounce + batch updates |
| Connection limits | Server maxed out | Use connection pooling, Redis adapter |
Monitoring WebSocket Health
// middleware.ts
export function monitorWebSocketHealth(socket: any, room: string) {
const heartbeatInterval = setInterval(() => {
socket.emit('ping', { timestamp: Date.now() });
}, 30000); // Every 30 seconds
socket.on('pong', (data) => {
const latency = Date.now() - data.timestamp;
console.log(`Latency: ${latency}ms`);
});
socket.on('disconnect', () => {
clearInterval(heartbeatInterval);
});
}
Conclusion
WebSocket-powered real-time applications create the most engaging user experiences. The technical complexity is real—managing connections, handling disconnects, scaling across servers—but the patterns I've shared make it manageable.
The key insights:
- Start simple (single server), scale horizontally with Redis adapter when needed
- Handle disconnects gracefully — queue changes, retry, show connection status
- Implement conflict resolution — Operational Transform handles simultaneous edits
- Monitor and rate limit — prevent broadcast storms and resource exhaustion
- Test edge cases — network drops, slow clients, concurrent updates
Master these patterns, and you'll build real-time applications that your users will love—because they'll feel impossibly responsive and smooth.