Real-Time Support Chat Implementation on Website
Support chat enables users to communicate with operators in real-time. Consists of client widget, operator interface and server routing logic.
Architecture
User (widget) Operator (admin UI)
│ │
└─── WebSocket ──► Chat server ◄───────────┘
│
PostgreSQL (history)
Redis (active sessions)
Server Logic
import { Server } from 'socket.io';
// Queue of unanswered chats
const waitingQueue: Map<string, ChatSession> = new Map();
const activeSessions: Map<string, ChatSession> = new Map();
io.on('connection', async (socket) => {
const { role } = socket.data; // 'user' or 'operator'
if (role === 'user') {
handleUserConnection(socket);
} else if (role === 'operator') {
handleOperatorConnection(socket);
}
});
async function handleUserConnection(socket: Socket) {
const userId = socket.data.userId;
// Create or restore session
let session = await sessionRepo.findActiveByUser(userId);
if (!session) {
session = await sessionRepo.create({
userId,
status: 'waiting',
startedAt: new Date()
});
}
socket.join(`session:${session.id}`);
if (session.status === 'waiting') {
waitingQueue.set(session.id, session);
socket.emit('queue:position', {
position: waitingQueue.size,
estimatedWait: waitingQueue.size * 2 // min
});
io.to('operators').emit('queue:updated', { count: waitingQueue.size });
}
socket.on('message:send', async ({ content }) => {
const message = await messageRepo.create({
sessionId: session.id,
senderId: userId,
senderRole: 'user',
content,
sentAt: new Date()
});
io.to(`session:${session.id}`).emit('message:new', message);
});
socket.on('disconnect', () => {
io.to(`session:${session.id}`).emit('user:offline', { userId });
});
}
async function handleOperatorConnection(socket: Socket) {
socket.join('operators');
// Operator accepts chat from queue
socket.on('session:accept', async ({ sessionId }) => {
const session = waitingQueue.get(sessionId);
if (!session) return;
waitingQueue.delete(sessionId);
await sessionRepo.assign(sessionId, socket.data.userId);
socket.join(`session:${sessionId}`);
io.to(`session:${sessionId}`).emit('operator:joined', {
operatorId: socket.data.userId,
operatorName: socket.data.user.name
});
io.to('operators').emit('queue:updated', { count: waitingQueue.size });
// Send message history to operator
const history = await messageRepo.findBySession(sessionId);
socket.emit('session:history', history);
});
socket.on('message:send', async ({ sessionId, content }) => {
const message = await messageRepo.create({
sessionId,
senderId: socket.data.userId,
senderRole: 'operator',
content
});
io.to(`session:${sessionId}`).emit('message:new', message);
});
// Close chat
socket.on('session:close', async ({ sessionId, resolution }) => {
await sessionRepo.close(sessionId, resolution);
io.to(`session:${sessionId}`).emit('session:closed', { resolution });
});
}
Client Widget (React)
function SupportWidget() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [status, setStatus] = useState<'connecting' | 'waiting' | 'active' | 'closed'>('connecting');
const [input, setInput] = useState('');
const socketRef = useRef<Socket>();
useEffect(() => {
const socket = io('/support', {
auth: { token: getGuestToken() }
});
socket.on('queue:position', ({ position }) => {
setStatus('waiting');
setMessages([{ system: true, text: `You in queue. Position: ${position}` }]);
});
socket.on('operator:joined', ({ operatorName }) => {
setStatus('active');
setMessages(prev => [...prev, {
system: true, text: `${operatorName} joined`
}]);
});
socket.on('message:new', (msg) => {
setMessages(prev => [...prev, msg]);
});
socketRef.current = socket;
return () => { socket.disconnect(); };
}, []);
const sendMessage = () => {
if (!input.trim()) return;
socketRef.current?.emit('message:send', { content: input });
setInput('');
};
return (
<div className={`chat-widget ${isOpen ? 'open' : ''}`}>
<button className="chat-toggle" onClick={() => setIsOpen(!isOpen)}>
💬 Support
</button>
{isOpen && (
<div className="chat-window">
<MessageList messages={messages} />
{status === 'active' && (
<div className="chat-input">
<input value={input} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()} />
<button onClick={sendMessage}>Send</button>
</div>
)}
</div>
)}
</div>
);
}
Operator Notifications
// If operator not in browser — notify via Telegram/email
async function notifyOperatorsNewChat(session: ChatSession) {
const onlineOperators = await redis.smembers('online:operators');
if (onlineOperators.length === 0) {
await telegramBot.sendMessage(OPERATORS_CHAT_ID,
`🆕 New support request\nUser: ${session.userId}\nFirst message: ${session.firstMessage}`
);
}
}
Timeline
Basic chat with queue and history: 2–3 weeks. Full: widget + operator UI + notifications + rating: 4–6 weeks.







