Skip to content
Sameer Sabir
Next.jsFeatured

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.

Sameer SabirUpdated 13 min read
  • Next.js
  • WebSockets
  • Real-Time
  • Collaboration
  • Socket.io
  • TypeScript
  • Scaling

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?

TechnologyLatencyBrowser SupportScalabilityUse Case
WebSocket<100msNativeModerate (with Redis pub/sub)Chat, collaboration, games
Server-Sent Events50-500msNativeEasyNotifications, streaming data
gRPC-web<50msRequires proxyHighHigh-frequency trading, data
Polling1-5sNativeGoodLegacy, simple updates
GraphQL Subscriptions<100msWith upgradingModerateTyped 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

ProblemCauseSolution
Memory leakEvent listeners not cleaned upUse useEffect cleanup in hooks
Message orderMultiple servers, different pathsUse event sequence numbers
Lost updatesDisconnect during sendQueue + retry on reconnect
Broadcast stormMany clients, too many messagesDebounce + batch updates
Connection limitsServer maxed outUse 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:

  1. Start simple (single server), scale horizontally with Redis adapter when needed
  2. Handle disconnects gracefully — queue changes, retry, show connection status
  3. Implement conflict resolution — Operational Transform handles simultaneous edits
  4. Monitor and rate limit — prevent broadcast storms and resource exhaustion
  5. 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.

Real-time features are straightforward with ten users and genuinely hard with ten thousand. Building for the second case is React and Next.js web application development work — tell me what you are building.

  1. Streaming AI Responses in Next.js: Building Real-Time User Experiences with LLMsNext.js · 12 min
  2. Building a Website Audit Platform with Next.js, Playwright, Lighthouse, and axe-coreNext.js · 10 min
  3. Building AI-Powered Chat Features with Google Generative AI and Next.jsNext.js · 11 min

Next step

Have a project that needs this kind of work?

I build React and Next.js products end to end, including the performance and AI work these posts cover. Tell me where yours is stuck.