Реализация 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 дней |







