Build Real-Time Applications
Real-Time Communication with WebSockets
5 min read
Project Goal: Build a real-time chat application with presence indicators using WebSockets.
Understanding Real-Time Options
| Technology | Use Case | Pros | Cons |
|---|---|---|---|
| WebSockets | Bi-directional, low latency | Full duplex, efficient | Connection management |
| Server-Sent Events | Server-to-client updates | Simple, HTTP-based | One-way only |
| Long Polling | Fallback compatibility | Works everywhere | Inefficient |
| WebRTC | P2P, media streaming | Direct connection | Complex setup |
Project Setup Prompt
Build a real-time chat application:
## Tech Stack
- Next.js 15 with App Router
- Socket.io for WebSocket management
- Redis for pub/sub (multi-server support)
- Zustand for client state
## Features
1. Real-time messaging
2. User presence (online/offline/typing)
3. Message delivery status (sent/delivered/read)
4. Room/channel support
5. Reconnection handling
## Project Structure
/app /api/socket route.ts # Socket.io setup /chat page.tsx # Chat interface /lib /socket server.ts # Server socket handlers client.ts # Client socket hooks /redis client.ts # Redis connection /components /chat chat-room.tsx message-list.tsx presence-indicator.tsx
Socket.io Server Setup
// lib/socket/server.ts
import { Server as SocketIOServer } from 'socket.io';
import { Server as HTTPServer } from 'http';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
let io: SocketIOServer | null = null;
interface Message {
id: string;
roomId: string;
userId: string;
content: string;
timestamp: Date;
status: 'sent' | 'delivered' | 'read';
}
interface UserPresence {
odIds: string;
status: 'online' | 'away' | 'offline';
lastSeen: Date;
typing?: { roomId: string; timestamp: Date };
}
export async function initSocketServer(httpServer: HTTPServer) {
if (io) return io;
io = new SocketIOServer(httpServer, {
cors: {
origin: process.env.NEXT_PUBLIC_APP_URL,
methods: ['GET', 'POST'],
},
transports: ['websocket', 'polling'],
});
// Redis adapter for horizontal scaling
if (process.env.REDIS_URL) {
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
}
// Connection handling
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id}`);
// User authentication
socket.on('authenticate', async (userId: string) => {
socket.data.userId = odIds;
socket.join(`user:${odIds}`);
// Update presence
await updatePresence(odIds, 'online');
io?.emit('presence:update', { userId, status: 'online' });
});
// Join room
socket.on('room:join', async (roomId: string) => {
socket.join(`room:${roomId}`);
socket.to(`room:${roomId}`).emit('user:joined', {
userId: socket.data.userId,
roomId,
});
});
// Leave room
socket.on('room:leave', async (roomId: string) => {
socket.leave(`room:${roomId}`);
socket.to(`room:${roomId}`).emit('user:left', {
userId: socket.data.userId,
roomId,
});
});
// Send message
socket.on('message:send', async (data: {
roomId: string;
content: string;
}) => {
const message: Message = {
id: crypto.randomUUID(),
roomId: data.roomId,
userId: socket.data.userId,
content: data.content,
timestamp: new Date(),
status: 'sent',
};
// Broadcast to room
io?.to(`room:${data.roomId}`).emit('message:new', message);
// Confirm delivery to sender
socket.emit('message:sent', { id: message.id });
});
// Typing indicator
socket.on('typing:start', (roomId: string) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId: socket.data.userId,
roomId,
isTyping: true,
});
});
socket.on('typing:stop', (roomId: string) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId: socket.data.userId,
roomId,
isTyping: false,
});
});
// Message read receipt
socket.on('message:read', (data: { messageId: string; roomId: string }) => {
io?.to(`room:${data.roomId}`).emit('message:status', {
messageId: data.messageId,
status: 'read',
readBy: socket.data.userId,
});
});
// Disconnect
socket.on('disconnect', async () => {
if (socket.data.userId) {
await updatePresence(socket.data.userId, 'offline');
io?.emit('presence:update', {
userId: socket.data.userId,
status: 'offline',
});
}
});
});
return io;
}
async function updatePresence(userId: string, status: string) {
// Store in Redis or database
console.log(`Presence update: ${userId} is ${status}`);
}
export function getIO() {
return io;
}
Client Socket Hook
// lib/socket/client.ts
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { create } from 'zustand';
interface Message {
id: string;
roomId: string;
userId: string;
content: string;
timestamp: Date;
status: 'sent' | 'delivered' | 'read';
}
interface TypingUser {
userId: string;
roomId: string;
}
interface ChatState {
messages: Message[];
typingUsers: TypingUser[];
onlineUsers: Set<string>;
addMessage: (message: Message) => void;
updateMessageStatus: (messageId: string, status: Message['status']) => void;
setTyping: (userId: string, roomId: string, isTyping: boolean) => void;
setUserOnline: (userId: string, isOnline: boolean) => void;
}
export const useChatStore = create<ChatState>((set) => ({
messages: [],
typingUsers: [],
onlineUsers: new Set(),
addMessage: (message) =>
set((state) => ({
messages: [...state.messages, message],
})),
updateMessageStatus: (messageId, status) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === messageId ? { ...m, status } : m
),
})),
setTyping: (userId, roomId, isTyping) =>
set((state) => ({
typingUsers: isTyping
? [...state.typingUsers.filter((t) => t.userId !== userId), { userId, roomId }]
: state.typingUsers.filter((t) => t.userId !== userId),
})),
setUserOnline: (userId, isOnline) =>
set((state) => {
const newSet = new Set(state.onlineUsers);
if (isOnline) {
newSet.add(userId);
} else {
newSet.delete(userId);
}
return { onlineUsers: newSet };
}),
}));
export function useSocket(userId: string) {
const socketRef = useRef<Socket | null>(null);
const store = useChatStore();
useEffect(() => {
// Initialize socket connection
socketRef.current = io(process.env.NEXT_PUBLIC_SOCKET_URL || '', {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
const socket = socketRef.current;
// Authenticate
socket.emit('authenticate', userId);
// Message handlers
socket.on('message:new', (message: Message) => {
store.addMessage(message);
});
socket.on('message:status', (data: { messageId: string; status: Message['status'] }) => {
store.updateMessageStatus(data.messageId, data.status);
});
// Typing handlers
socket.on('typing:update', (data: { userId: string; roomId: string; isTyping: boolean }) => {
store.setTyping(data.userId, data.roomId, data.isTyping);
});
// Presence handlers
socket.on('presence:update', (data: { userId: string; status: string }) => {
store.setUserOnline(data.userId, data.status === 'online');
});
// Connection handlers
socket.on('connect', () => {
console.log('Socket connected');
});
socket.on('disconnect', () => {
console.log('Socket disconnected');
});
socket.on('connect_error', (error) => {
console.error('Socket connection error:', error);
});
return () => {
socket.disconnect();
};
}, [userId, store]);
const joinRoom = useCallback((roomId: string) => {
socketRef.current?.emit('room:join', roomId);
}, []);
const leaveRoom = useCallback((roomId: string) => {
socketRef.current?.emit('room:leave', roomId);
}, []);
const sendMessage = useCallback((roomId: string, content: string) => {
socketRef.current?.emit('message:send', { roomId, content });
}, []);
const startTyping = useCallback((roomId: string) => {
socketRef.current?.emit('typing:start', roomId);
}, []);
const stopTyping = useCallback((roomId: string) => {
socketRef.current?.emit('typing:stop', roomId);
}, []);
const markAsRead = useCallback((messageId: string, roomId: string) => {
socketRef.current?.emit('message:read', { messageId, roomId });
}, []);
return {
socket: socketRef.current,
joinRoom,
leaveRoom,
sendMessage,
startTyping,
stopTyping,
markAsRead,
};
}
Chat Room Component
// components/chat/chat-room.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import { useSocket, useChatStore } from '@/lib/socket/client';
import { MessageList } from './message-list';
import { PresenceIndicator } from './presence-indicator';
interface ChatRoomProps {
roomId: string;
userId: string;
userName: string;
}
export function ChatRoom({ roomId, userId, userName }: ChatRoomProps) {
const [message, setMessage] = useState('');
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { joinRoom, leaveRoom, sendMessage, startTyping, stopTyping } =
useSocket(userId);
const { messages, typingUsers } = useChatStore();
const roomMessages = messages.filter((m) => m.roomId === roomId);
const roomTypingUsers = typingUsers.filter(
(t) => t.roomId === roomId && t.userId !== userId
);
useEffect(() => {
joinRoom(roomId);
return () => leaveRoom(roomId);
}, [roomId, joinRoom, leaveRoom]);
const handleTyping = () => {
startTyping(roomId);
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Stop typing indicator after 2 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
stopTyping(roomId);
}, 2000);
};
const handleSend = () => {
if (!message.trim()) return;
sendMessage(roomId, message);
setMessage('');
stopTyping(roomId);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b p-4">
<h2 className="font-semibold">Chat Room</h2>
<PresenceIndicator roomId={roomId} />
</div>
{/* Messages */}
<MessageList
messages={roomMessages}
currentUserId={userId}
/>
{/* Typing indicator */}
{roomTypingUsers.length > 0 && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{roomTypingUsers.length === 1
? `Someone is typing...`
: `${roomTypingUsers.length} people are typing...`}
</div>
)}
{/* Input */}
<div className="border-t p-4">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => {
setMessage(e.target.value);
handleTyping();
}}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="flex-1 rounded-lg border px-4 py-2"
/>
<button
onClick={handleSend}
disabled={!message.trim()}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
);
}
Message List Component
// components/chat/message-list.tsx
'use client';
import { useEffect, useRef } from 'react';
import { Check, CheckCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Message {
id: string;
userId: string;
content: string;
timestamp: Date;
status: 'sent' | 'delivered' | 'read';
}
interface MessageListProps {
messages: Message[];
currentUserId: string;
}
export function MessageList({ messages, currentUserId }: MessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{messages.map((message) => {
const isOwn = message.userId === currentUserId;
return (
<div
key={message.id}
className={cn(
'flex',
isOwn ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[70%] rounded-lg px-4 py-2',
isOwn
? 'bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
<p>{message.content}</p>
<div className="mt-1 flex items-center justify-end gap-1 text-xs opacity-70">
<span>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
{isOwn && (
<span>
{message.status === 'read' ? (
<CheckCheck className="h-3 w-3 text-blue-400" />
) : message.status === 'delivered' ? (
<CheckCheck className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
Key Takeaways
- Socket.io simplifies WebSocket management with fallbacks
- Redis adapter enables horizontal scaling across servers
- Zustand provides lightweight real-time state management
- Typing indicators need debouncing to avoid excessive events
- Read receipts improve user engagement visibility
الاتصال اللحظي مع WebSockets
هدف المشروع: بناء تطبيق دردشة لحظي مع مؤشرات الحضور باستخدام WebSockets.
فهم خيارات الوقت الفعلي
| التقنية | حالة الاستخدام | الإيجابيات | السلبيات |
|---|---|---|---|
| WebSockets | ثنائي الاتجاه، تأخير منخفض | duplex كامل، فعال | إدارة الاتصال |
| Server-Sent Events | تحديثات الخادم للعميل | بسيط، مبني على HTTP | اتجاه واحد فقط |
| Long Polling | توافق احتياطي | يعمل في كل مكان | غير فعال |
| WebRTC | P2P، بث الوسائط | اتصال مباشر | إعداد معقد |
إعداد خادم Socket.io
// lib/socket/server.ts
import { Server as SocketIOServer } from 'socket.io';
import { Server as HTTPServer } from 'http';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
let io: SocketIOServer | null = null;
interface Message {
id: string;
roomId: string;
userId: string;
content: string;
timestamp: Date;
status: 'sent' | 'delivered' | 'read';
}
export async function initSocketServer(httpServer: HTTPServer) {
if (io) return io;
io = new SocketIOServer(httpServer, {
cors: {
origin: process.env.NEXT_PUBLIC_APP_URL,
methods: ['GET', 'POST'],
},
transports: ['websocket', 'polling'],
});
// محول Redis للتوسع الأفقي
if (process.env.REDIS_URL) {
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
}
// معالجة الاتصال
io.on('connection', (socket) => {
console.log(`عميل متصل: ${socket.id}`);
// مصادقة المستخدم
socket.on('authenticate', async (userId: string) => {
socket.data.userId = userId;
socket.join(`user:${userId}`);
// تحديث الحضور
await updatePresence(userId, 'online');
io?.emit('presence:update', { userId, status: 'online' });
});
// الانضمام للغرفة
socket.on('room:join', async (roomId: string) => {
socket.join(`room:${roomId}`);
socket.to(`room:${roomId}`).emit('user:joined', {
userId: socket.data.userId,
roomId,
});
});
// إرسال رسالة
socket.on('message:send', async (data: {
roomId: string;
content: string;
}) => {
const message: Message = {
id: crypto.randomUUID(),
roomId: data.roomId,
userId: socket.data.userId,
content: data.content,
timestamp: new Date(),
status: 'sent',
};
// البث للغرفة
io?.to(`room:${data.roomId}`).emit('message:new', message);
// تأكيد التسليم للمرسل
socket.emit('message:sent', { id: message.id });
});
// مؤشر الكتابة
socket.on('typing:start', (roomId: string) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId: socket.data.userId,
roomId,
isTyping: true,
});
});
socket.on('typing:stop', (roomId: string) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId: socket.data.userId,
roomId,
isTyping: false,
});
});
// قطع الاتصال
socket.on('disconnect', async () => {
if (socket.data.userId) {
await updatePresence(socket.data.userId, 'offline');
io?.emit('presence:update', {
userId: socket.data.userId,
status: 'offline',
});
}
});
});
return io;
}
async function updatePresence(userId: string, status: string) {
console.log(`تحديث الحضور: ${userId} هو ${status}`);
}
هوك Socket العميل
// lib/socket/client.ts
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { create } from 'zustand';
interface Message {
id: string;
roomId: string;
userId: string;
content: string;
timestamp: Date;
status: 'sent' | 'delivered' | 'read';
}
interface ChatState {
messages: Message[];
typingUsers: { userId: string; roomId: string }[];
onlineUsers: Set<string>;
addMessage: (message: Message) => void;
updateMessageStatus: (messageId: string, status: Message['status']) => void;
setTyping: (userId: string, roomId: string, isTyping: boolean) => void;
setUserOnline: (userId: string, isOnline: boolean) => void;
}
export const useChatStore = create<ChatState>((set) => ({
messages: [],
typingUsers: [],
onlineUsers: new Set(),
addMessage: (message) =>
set((state) => ({
messages: [...state.messages, message],
})),
updateMessageStatus: (messageId, status) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === messageId ? { ...m, status } : m
),
})),
setTyping: (userId, roomId, isTyping) =>
set((state) => ({
typingUsers: isTyping
? [...state.typingUsers.filter((t) => t.userId !== userId), { userId, roomId }]
: state.typingUsers.filter((t) => t.userId !== userId),
})),
setUserOnline: (userId, isOnline) =>
set((state) => {
const newSet = new Set(state.onlineUsers);
if (isOnline) {
newSet.add(userId);
} else {
newSet.delete(userId);
}
return { onlineUsers: newSet };
}),
}));
export function useSocket(userId: string) {
const socketRef = useRef<Socket | null>(null);
const store = useChatStore();
useEffect(() => {
socketRef.current = io(process.env.NEXT_PUBLIC_SOCKET_URL || '', {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
const socket = socketRef.current;
// المصادقة
socket.emit('authenticate', userId);
// معالجات الرسائل
socket.on('message:new', (message: Message) => {
store.addMessage(message);
});
// معالجات الحضور
socket.on('presence:update', (data: { userId: string; status: string }) => {
store.setUserOnline(data.userId, data.status === 'online');
});
return () => {
socket.disconnect();
};
}, [userId, store]);
const joinRoom = useCallback((roomId: string) => {
socketRef.current?.emit('room:join', roomId);
}, []);
const sendMessage = useCallback((roomId: string, content: string) => {
socketRef.current?.emit('message:send', { roomId, content });
}, []);
const startTyping = useCallback((roomId: string) => {
socketRef.current?.emit('typing:start', roomId);
}, []);
const stopTyping = useCallback((roomId: string) => {
socketRef.current?.emit('typing:stop', roomId);
}, []);
return {
socket: socketRef.current,
joinRoom,
sendMessage,
startTyping,
stopTyping,
};
}
النقاط الرئيسية
- Socket.io يبسط إدارة WebSocket مع بدائل احتياطية
- محول Redis يُمكّن التوسع الأفقي عبر الخوادم
- Zustand يوفر إدارة حالة خفيفة للوقت الفعلي
- مؤشرات الكتابة تحتاج تأخيراً لتجنب الأحداث المفرطة
- إيصالات القراءة تحسن رؤية تفاعل المستخدم