Реализация live-чата поддержки на WebSocket
Представьте: интернет-магазин с 10 000 посетителей в день теряет до 30% лидов, потому что на вопросы отвечают через час. Клиент уходит к конкуренту, где есть мгновенный чат. Онлайн-чат на сайт решает эту проблему: оператор видит пользователя и может сразу помочь. По данным Statista, чат на сайте увеличивает конверсию на 20-40%. Экономия на поддержке достигает 30-50% за счёт автоматизации типовых вопросов и сокращения времени ответа. Но реализация сложнее подключения готового скрипта — нужна надёжная архитектура, которая выдерживает тысячи одновременных соединений, обрабатывает очередь и не теряет сообщения при разрывах. Мы проектируем и внедряем такие системы для e-commerce, SaaS и финтеха. Наш опыт — более 10 проектов аналогичной сложности, и мы гарантируем стабильную работу под нагрузкой до 10K одновременных сессий.
Мы берём на себя полный цикл: от проектирования WebSocket-сервера до деплоя и мониторинга. Ниже — технические детали, код и сроки.
В основе — WebSocket-сервер на Node.js и Socket.IO. Этот выбор обусловлен производительностью и встроенным механизмом переподключения.
Почему WebSocket, а не Long Polling?
Long Polling создаёт лишнюю нагрузку: на каждый запрос — новый HTTP-пакет с заголовками. WebSocket устанавливает постоянное соединение, уменьшая трафик в 10+ раз и задержку до 50 мс — это в 6 раз быстрее Long Polling. Для чата с большой аудиторией это критично.
| Характеристика | WebSocket | Long Polling |
|---|---|---|
| Задержка | ~50 мс | ~300 мс+ |
| Трафик на сообщение | ~100 байт | ~800 байт (с хидерами) |
| Масштабирование | Redis adapter, Nginx | round-robin, сложнее |
| Автоматический reconnect | Встроен | Нужна реализация |
На практике разница заметна при 100+ одновременных сессиях: WebSocket потребляет на 60% меньше ресурсов CPU.
Как обеспечить стабильное WebSocket-соединение при разрывах?
Socket.IO (см. документацию) предоставляет встроенные механизмы: heartbeat, таймауты, exponential backoff. Мы настраиваем pingInterval и pingTimeout под нагрузку вашего проекта. При потере соединения клиент автоматически переподключается, а сервер восстанавливает контекст чата. История сообщений хранится в БД, поэтому ни одно сообщение не потеряется.
Что происходит при падении сервера?
Socket.IO сохраняет состояние в Redis. При перезапуске сервер восстанавливает сессии. Сообщения, не доставленные, сохраняются в БД и отправляются позже.
Пример масштабирования на 10K соединений
Масштабирование WebSocket до 10K соединений обеспечивается Redis adapter и Nginx. Каждый сервер обрабатывает до 1000 соединений, при превышении добавляем новые инстансы. Мониторим через Prometheus+Grafana.
Архитектура и стек
Система состоит из трёх частей: сервер на Node.js + Socket.IO, виджет для посетителя (React) и панель оператора. Данные хранятся в PostgreSQL с индексами по chat_id и 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 },
});
// Авторизация операторов
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;
// Посетитель начинает чат
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;
// Уведомить всех операторов о новом чате
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 });
});
// Оператор принимает чат
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}`);
// Уведомить посетителя
io.to(`chat:${chatId}`).emit('chat:accepted', {
operator: { name: socket.data.user.name, avatar: socket.data.user.avatar },
});
});
// Сообщение
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,
});
// Обновить время последнего сообщения в чате
await chat.update({ last_message_at: new Date() });
});
// Печатает...
socket.on('chat:typing', ({ chatId }) => {
socket.to(`chat:${chatId}`).emit('chat:typing', {
sender_type: isOperator ? 'operator' : 'visitor',
});
});
// Закрыть чат
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');
// Отправить транскрипт на email
const chat = await Chat.findByPk(chatId, { include: [Message] });
if (chat.visitor_email) {
await emailService.sendTranscript(chat);
}
});
// Операторы присоединяются к комнате для получения новых чатов
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) {
// Посетитель ушёл
io.to(`chat:${socket.data.chatId}`).emit('visitor:left');
}
});
});
React: виджет для посетителя
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} подключился к чату`,
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: 'Посетитель',
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="Открыть чат">
💬
</button>
);
}
return (
<div className="chat-widget" role="dialog" aria-label="Чат поддержки">
<header>
<h2>{operator ? `Чат с ${operator.name}` : 'Чат поддержки'}</h2>
<button onClick={() => setIsOpen(false)} aria-label="Закрыть">×</button>
</header>
<div className="messages" aria-live="polite">
{status === 'idle' && (
<div className="start-screen">
<p>Привет! Чем можем помочь?</p>
<button onClick={startChat}>Начать чат</button>
</div>
)}
{status === 'waiting' && <p>Ищем свободного оператора...</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('ru-RU', { 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="Напишите сообщение..."
/>
<button onClick={sendMessage} disabled={!input.trim()}>Отправить</button>
</footer>
)}
</div>
);
}
Панель оператора
function OperatorDashboard() {
const [chats, setChats] = useState<Chat[]>([]);
const [activeChat, setActiveChat] = useState<string | null>(null);
// Получить очередь ожидающих чатов
socket.on('chat:new', (chat) => {
setChats(prev => [chat, ...prev]);
// Звуковое уведомление
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>Ожидают ({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)}>Принять</button>
</div>
))}
</aside>
{activeChat && <ChatWindow chatId={activeChat} socket={socket} />}
</div>
);
}
Процесс работы
- Аналитика: определяем ожидаемую нагрузку, количество операторов и требования к интеграции.
- Проектирование: архитектура сервера, схема БД, контракты API.
- Реализация: разработка сервера, виджета и панели оператора параллельно.
- Тестирование: нагрузочное тестирование (до 10K соединений), проверка отказоустойчивости.
- Деплой: настройка Nginx (proxy WebSocket), Redis adapter для горизонтального масштабирования, мониторинг через Prometheus+Grafana.
Интеграция с CRM через вебхуки позволяет передавать историю диалогов. При необходимости добавляем очередь (Bull/RabbitMQ) для офлайн-сообщений и транскрипты на почту.
Обработка офлайн-сообщений
Если оператор не принял чат в течение заданного таймаута, сообщение сохраняется в БД, а на почту клиента отправляется транскрипт с обещанием ответить в рабочее время. Для бизнеса с высокой нагрузкой мы добавляем эскалацию: если очередь превышает лимит, подключается второй оператор или срабатывает интеграция с Telegram.
Что входит в результат
- Документация API и инструкция по развёртыванию
- Готовый Docker-образ для сервера
- Исходный код виджета и панели оператора
- Обучение команды поддержки (1 час онлайн)
- Бесплатная поддержка 3 месяца после запуска
Наш опыт — более 10 проектов аналогичной сложности. Мы даём гарантию на код 12 месяцев. По данным Statista, чат на сайте увеличивает конверсию на 20-40%. Реализация чата поддержки с использованием WebSocket — это инвестиция в лояльность клиентов. Свяжитесь с нами, чтобы обсудить детали и получить демо-доступ к панели оператора. Закажите разработку live-чата под ваши задачи и получите бесплатную консультацию.
Срок реализации
| Задача | Срок |
|---|---|
| Socket.IO сервер + базовые события | 2–3 дня |
| Виджет посетителя (React) | 2–3 дня |
| Панель оператора | 3–4 дня |
| История чатов + транскрипты на email | +1–2 дня |
| Полная система с очередью и мониторингом | 8–12 дней |







