WebSocket-Based Live Support Chat with Queue and History
Imagine an online store with 10,000 daily visitors losing up to 30% of leads because responses take an hour. A customer leaves for a competitor that offers instant chat. An online chat on the website solves this: an operator sees the user and can help immediately. According to Statista, website chat increases conversion by 20-40%. Support costs drop by 30-50% thanks to automation of typical questions and reduced response time. But implementation is more complex than plugging in a ready-made script—it requires a robust architecture that handles thousands of concurrent connections, manages a queue, and never loses messages during disconnects. We design and deploy such systems for e-commerce, SaaS, and fintech. Our experience includes over 10 projects of similar complexity, and we guarantee stable operation under loads of up to 10K concurrent sessions.
We take on the full cycle: from WebSocket server design to deployment and monitoring. Below are technical details, code, and timelines.
At the core is a WebSocket server on Node.js and Socket.IO. This choice is driven by performance and built-in reconnection mechanisms.
Why WebSocket Instead of Long Polling?
Long Polling creates unnecessary overhead: each request generates a new HTTP packet with headers. WebSocket maintains a persistent connection, reducing traffic by 10x or more and latency down to 50 ms—6x faster than Long Polling. For a chat with a large audience, this is critical.
| Feature | WebSocket | Long Polling |
|---|---|---|
| Latency | ~50 ms | ~300 ms+ |
| Traffic per message | ~100 bytes | ~800 bytes (with headers) |
| Scaling | Redis adapter, Nginx | round-robin, more complex |
| Automatic reconnect | Built-in | Requires implementation |
In practice, the difference is noticeable at 100+ concurrent sessions: WebSocket consumes 60% less CPU resources.
How to Ensure Stable WebSocket Connection During Disconnects?
Socket.IO (see documentation) provides built-in mechanisms: heartbeat, timeouts, exponential backoff. We configure pingInterval and pingTimeout for your project's load. On connection loss, the client automatically reconnects, and the server restores the chat context. Message history is stored in the database, so no message is lost.
What Happens When the Server Crashes?
Socket.IO maintains state in Redis. Upon restart, the server restores sessions. Undelivered messages are saved in the database and sent later.
Example of scaling to 10K connections
Scaling WebSocket to 10K connections is achieved with a Redis adapter and Nginx. Each server handles up to 1000 connections; when exceeded, new instances are added. Monitoring via Prometheus+Grafana.
Architecture and Tech Stack
The system consists of three parts: a server on Node.js + Socket.IO, a visitor widget (React), and an operator panel. Data is stored in PostgreSQL with indexes on chat_id and sender_type.
Backend: Node.js + Socket.IO
import { Server } from 'socket.io';
import { createServer } from 'http';
const io = new Server(createServer(), {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
});
// Operator authorization
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
if (token) {
const user = await verifyToken(token);
if (user?.role === 'operator') {
socket.data.user = user;
socket.data.isOperator = true;
}
}
next();
});
io.on('connection', (socket) => {
const { chatId, isOperator } = socket.data;
// Visitor starts a chat
socket.on('chat:start', async (data) => {
const chat = await Chat.create({
visitor_name: data.name,
visitor_email: data.email,
page_url: data.pageUrl,
status: 'waiting',
});
socket.join(`chat:${chat.id}`);
socket.data.chatId = chat.id;
// Notify all operators about a new chat
io.to('operators').emit('chat:new', {
id: chat.id,
visitor_name: chat.visitor_name,
page_url: chat.page_url,
started_at: chat.created_at,
});
socket.emit('chat:started', { chatId: chat.id });
});
// Operator accepts a chat
socket.on('chat:accept', async ({ chatId }) => {
if (!isOperator) return;
const chat = await Chat.findByPk(chatId);
if (!chat || chat.status !== 'waiting') return;
await chat.update({ operator_id: socket.data.user.id, status: 'active', accepted_at: new Date() });
socket.join(`chat:${chatId}`);
// Notify visitor
io.to(`chat:${chatId}`).emit('chat:accepted', {
operator: { name: socket.data.user.name, avatar: socket.data.user.avatar },
});
});
// Message
socket.on('chat:message', async ({ chatId, text }) => {
const chat = await Chat.findByPk(chatId);
if (!chat) return;
const message = await Message.create({
chat_id: chatId,
sender_type: isOperator ? 'operator' : 'visitor',
sender_id: socket.data.user?.id ?? null,
text: sanitize(text),
});
io.to(`chat:${chatId}`).emit('chat:message', {
id: message.id,
text: message.text,
sender_type: message.sender_type,
sent_at: message.created_at,
});
// Update last message time in the chat
await chat.update({ last_message_at: new Date() });
});
// Typing...
socket.on('chat:typing', ({ chatId }) => {
socket.to(`chat:${chatId}`).emit('chat:typing', {
sender_type: isOperator ? 'operator' : 'visitor',
});
});
// Close chat
socket.on('chat:close', async ({ chatId }) => {
await Chat.update({ status: 'closed', closed_at: new Date() }, { where: { id: chatId } });
io.to(`chat:${chatId}`).emit('chat:closed');
// Send transcript via email
const chat = await Chat.findByPk(chatId, { include: [Message] });
if (chat.visitor_email) {
await emailService.sendTranscript(chat);
}
});
// Operators join a room to receive new chats
if (isOperator) {
socket.join('operators');
io.to('operators').emit('operator:online', { id: socket.data.user.id });
}
socket.on('disconnect', async () => {
if (isOperator) {
io.to('operators').emit('operator:offline', { id: socket.data.user?.id });
} else if (socket.data.chatId) {
// Visitor left
io.to(`chat:${socket.data.chatId}`).emit('visitor:left');
}
});
});
React: Visitor Widget
import { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
interface Message {
id: string;
text: string;
sender_type: 'visitor' | 'operator';
sent_at: string;
}
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<'idle' | 'starting' | 'waiting' | 'active' | 'closed'>('idle');
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [operator, setOperator] = useState<{ name: string } | null>(null);
const socketRef = useRef<Socket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
socketRef.current = io(import.meta.env.VITE_CHAT_SERVER);
const socket = socketRef.current;
socket.on('chat:started', ({ chatId }) => {
setChatId(chatId);
setStatus('waiting');
});
socket.on('chat:accepted', ({ operator }) => {
setOperator(operator);
setStatus('active');
setMessages(prev => [...prev, {
id: 'system-accepted',
text: `${operator.name} connected to the chat`,
sender_type: 'operator',
sent_at: new Date().toISOString(),
}]);
});
socket.on('chat:message', (message) => {
setMessages(prev => [...prev, message]);
});
socket.on('chat:closed', () => setStatus('closed'));
return () => { socket.disconnect(); };
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const startChat = () => {
setStatus('starting');
socketRef.current?.emit('chat:start', {
name: 'Visitor',
pageUrl: window.location.href,
});
};
const sendMessage = () => {
if (!input.trim() || !chatId) return;
socketRef.current?.emit('chat:message', { chatId, text: input });
setInput('');
};
if (!isOpen) {
return (
<button className="chat-trigger" onClick={() => setIsOpen(true)} aria-label="Open chat">
💬
</button>
);
}
return (
<div className="chat-widget" role="dialog" aria-label="Support chat">
<header>
<h2>{operator ? `Chat with ${operator.name}` : 'Support chat'}</h2>
<button onClick={() => setIsOpen(false)} aria-label="Close">×</button>
</header>
<div className="messages" aria-live="polite">
{status === 'idle' && (
<div className="start-screen">
<p>Hi! How can we help?</p>
<button onClick={startChat}>Start chat</button>
</div>
)}
{status === 'waiting' && <p>Looking for an available operator...</p>}
{messages.map(msg => (
<div key={msg.id} className={`message message--${msg.sender_type}`}>
<p>{msg.text}</p>
<time>{new Date(msg.sent_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</time>
</div>
))}
<div ref={messagesEndRef} />
</div>
{status === 'active' && (
<footer>
<input
value={input}
onChange={e => {
setInput(e.target.value);
socketRef.current?.emit('chat:typing', { chatId });
}}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), sendMessage())}
placeholder="Type a message..."
/>
<button onClick={sendMessage} disabled={!input.trim()}>Send</button>
</footer>
)}
</div>
);
}
Operator Panel
function OperatorDashboard() {
const [chats, setChats] = useState<Chat[]>([]);
const [activeChat, setActiveChat] = useState<string | null>(null);
// Get waiting chat queue
socket.on('chat:new', (chat) => {
setChats(prev => [chat, ...prev]);
// Sound notification
new Audio('/notification.mp3').play().catch(() => {});
});
const acceptChat = (chatId: string) => {
socket.emit('chat:accept', { chatId });
setActiveChat(chatId);
setChats(prev => prev.filter(c => c.id !== chatId));
};
return (
<div className="operator-dashboard">
<aside className="chat-queue">
<h2>Waiting ({chats.length})</h2>
{chats.map(chat => (
<div key={chat.id} className="queue-item">
<span>{chat.visitor_name}</span>
<span>{chat.page_url}</span>
<button onClick={() => acceptChat(chat.id)}>Accept</button>
</div>
))}
</aside>
{activeChat && <ChatWindow chatId={activeChat} socket={socket} />}
</div>
);
}
Work Process
- Analytics: Determine expected load, number of operators, and integration requirements.
- Design: Server architecture, database schema, API contracts.
- Implementation: Develop server, widget, and operator panel in parallel.
- Testing: Load testing (up to 10K connections), fault tolerance checks.
- Deployment: Configure Nginx (WebSocket proxy), Redis adapter for horizontal scaling, monitoring via Prometheus+Grafana.
Integration with CRM via webhooks allows transferring dialog history. If needed, we add a queue (Bull/RabbitMQ) for offline messages and email transcripts.
Handling Offline Messages
If an operator doesn't accept a chat within a given timeout, the message is saved to the database and a transcript is sent to the client's email with a promise to respond during business hours. For high-load businesses, we add escalation: if the queue exceeds a limit, a second operator is added or a Telegram integration is triggered.
What Is Included in the Result
- API documentation and deployment instructions
- Ready Docker image for the server
- Source code of the widget and operator panel
- Support team training (1 hour online)
- Free support for 3 months after launch
Our experience includes over 10 projects of similar complexity. We offer a 12-month code warranty. According to Statista, website chat increases conversion by 20-40%. Implementing a support chat using WebSocket is an investment in customer loyalty. Contact us to discuss details and get demo access to the operator panel. Order the development of a live chat for your tasks and get a free consultation.
Implementation Timeline
| Task | Timeline |
|---|---|
| Socket.IO server + basic events | 2–3 days |
| Visitor widget (React) | 2–3 days |
| Operator panel | 3–4 days |
| Chat history + email transcripts | +1–2 days |
| Full system with queue and monitoring | 8–12 days |







