Реализация Live Chat на сайте
Разработка чата реального времени — задача, где архитектурные решения решают всё. Например, выбор REST вместо WebSocket при 100+ одновременных пользователях приводит к задержкам и избыточному трафику. Мы реализовали более 20 чат-систем для разных проектов — от внутренней поддержки до масштабных продакшн-решений с нагрузкой до 10 000 соединений на инстансе. Чат реального времени не только улучшает пользовательский опыт, но и снижает нагрузку на службу поддержки, позволяя обрабатывать запросы в 3-4 раза быстрее.
Полноценный чат — это не просто WebSocket с текстом. Сюда входят история сообщений, индикатор печати, статусы прочтения, поддержка файлов, оптимистичные обновления UI и push-уведомления. Каждый элемент требует отдельного решения. Рассмотрим ключевые компоненты на примере стека Socket.IO, React и Zustand.
Почему WebSocket — стандарт для live-чата?
WebSocket обеспечивает двунаправленную связь с минимальной задержкой. В отличие от long-polling, он потребляет на 90% меньше трафика и не блокирует соединение. Протокол WebSocket описан в стандарте RFC 6455. Для масштабирования используем Redis-адаптер — все инстансы сервера синхронизируют комнаты через pub/sub. Это позволяет обрабатывать десятки тысяч параллельных сессий. WebSocket лучше SSE в 10 раз по задержке: при типичной реализации время отклика составляет < 50 мс против 200–500 мс у SSE.
Структура данных сообщений
interface ChatRoom {
id: string;
type: 'direct' | 'group' | 'support';
participants: string[]; // userIds
name?: string; // для групп
lastMessage?: Message;
unreadCount: number;
}
interface Message {
id: string;
roomId: string;
senderId: string;
type: 'text' | 'image' | 'file' | 'system';
content: string;
attachments?: Attachment[];
replyTo?: string; // id родительского сообщения
editedAt?: Date;
deletedAt?: Date;
status: 'sending' | 'sent' | 'delivered' | 'read';
createdAt: Date;
}
interface Attachment {
id: string;
type: 'image' | 'file';
url: string;
name: string;
size: number;
mimeType: string;
}
Серверная часть на Socket.IO
// server/chat.ts
import { Server, Socket } from 'socket.io';
import { db } from './db';
import { redisAdapter } from '@socket.io/redis-adapter';
export function initChat(io: Server) {
io.on('connection', async (socket: Socket) => {
const userId = socket.data.userId;
// Присоединение ко всем комнатам пользователя при подключении
const rooms = await db.chatRoom.findMany({
where: { participants: { has: userId } },
select: { id: true },
});
rooms.forEach(({ id }) => socket.join(`room:${id}`));
// Отправка сообщения
socket.on('message:send', async (payload: {
roomId: string;
content: string;
type: 'text' | 'image' | 'file';
replyTo?: string;
clientId: string; // временный id для оптимистичного обновления
}, ack) => {
// Проверяем доступ
const room = await db.chatRoom.findFirst({
where: { id: payload.roomId, participants: { has: userId } },
});
if (!room) return ack({ error: 'Access denied' });
const message = await db.message.create({
data: {
roomId: payload.roomId,
senderId: userId,
type: payload.type,
content: payload.content,
replyToId: payload.replyTo,
status: 'sent',
},
});
// Broadcast в комнату
io.to(`room:${payload.roomId}`).emit('message:new', message);
// ACK отправителю с серверным id
ack({ ok: true, message, clientId: payload.clientId });
});
// Typing indicator
socket.on('typing:start', ({ roomId }) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId,
roomId,
isTyping: true,
});
});
socket.on('typing:stop', ({ roomId }) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId,
roomId,
isTyping: false,
});
});
// Прочтение сообщений
socket.on('messages:read', async ({ roomId, upToMessageId }) => {
await db.messageRead.upsert({
where: { userId_roomId: { userId, roomId } },
update: { lastReadMessageId: upToMessageId, readAt: new Date() },
create: { userId, roomId, lastReadMessageId: upToMessageId, readAt: new Date() },
});
socket.to(`room:${roomId}`).emit('messages:read:update', {
userId,
roomId,
upToMessageId,
});
});
// История сообщений (пагинация курсором)
socket.on('messages:load', async ({ roomId, before, limit = 50 }, ack) => {
const messages = await db.message.findMany({
where: {
roomId,
...(before ? { createdAt: { lt: new Date(before) } } : {}),
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
take: limit + 1,
include: { sender: { select: { id: true, name: true, avatar: true } } },
});
ack({
messages: messages.slice(0, limit).reverse(),
hasMore: messages.length > limit,
nextCursor: messages.length > limit
? messages[limit - 1].createdAt.toISOString()
: null,
});
});
});
}
Клиентская реализация
Как работают оптимистичные обновления?
Сообщение отображается мгновенно, не дожидаясь сервера. При получении ACK — заменяется реальным объектом:
// store/chat.ts (Zustand)
interface ChatStore {
messages: Map<string, Message[]>;
pendingIds: Map<string, string>; // clientId -> roomId
addOptimistic: (roomId: string, content: string) => string;
confirmMessage: (clientId: string, serverMessage: Message) => void;
failMessage: (clientId: string) => void;
}
const useChatStore = create<ChatStore>((set, get) => ({
messages: new Map(),
pendingIds: new Map(),
addOptimistic(roomId, content) {
const clientId = `pending-${Date.now()}-${Math.random()}`;
const optimistic: Message = {
id: clientId,
roomId,
senderId: currentUserId,
type: 'text',
content,
status: 'sending',
createdAt: new Date(),
};
set((s) => {
const msgs = [...(s.messages.get(roomId) ?? []), optimistic];
s.messages.set(roomId, msgs);
s.pendingIds.set(clientId, roomId);
return { messages: new Map(s.messages) };
});
return clientId;
},
confirmMessage(clientId, serverMessage) {
set((s) => {
const roomId = s.pendingIds.get(clientId)!;
const msgs = s.messages.get(roomId) ?? [];
const idx = msgs.findIndex((m) => m.id === clientId);
if (idx !== -1) msgs[idx] = { ...serverMessage, status: 'sent' };
s.pendingIds.delete(clientId);
return { messages: new Map(s.messages) };
});
},
}));
// Отправка с оптимистичным обновлением
async function sendMessage(roomId: string, content: string) {
const clientId = useChatStore.getState().addOptimistic(roomId, content);
socket.emit('message:send', { roomId, content, type: 'text', clientId },
(response: { ok: boolean; message?: Message; clientId: string }) => {
if (response.ok) {
useChatStore.getState().confirmMessage(clientId, response.message!);
} else {
useChatStore.getState().failMessage(clientId);
}
}
);
}
Как реализовать индикатор печати и push-уведомления?
// В компоненте ввода
const typingTimeout = useRef<ReturnType<typeof setTimeout>>();
function handleInput(value: string) {
setDraft(value);
socket.emit('typing:start', { roomId });
clearTimeout(typingTimeout.current);
typingTimeout.current = setTimeout(() => {
socket.emit('typing:stop', { roomId });
}, 2000);
}
// Отображение
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
socket.on('typing:update', ({ userId, isTyping }) => {
setTypingUsers((prev) => {
const next = new Set(prev);
isTyping ? next.add(userId) : next.delete(userId);
return next;
});
});
// UI
{typingUsers.size > 0 && (
<div className="typing-indicator">
<span>{getUserNames(typingUsers)} печатает...</span>
<BouncingDots />
</div>
)}
// Push-уведомления для фоновых вкладок (service-worker.ts)
self.addEventListener('push', (event: PushEvent) => {
const data = event.data?.json();
event.waitUntil(
self.registration.showNotification(data.senderName, {
body: data.content,
icon: data.senderAvatar,
badge: '/badge.png',
data: { roomId: data.roomId, url: `/chat/${data.roomId}` },
})
);
});
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Web Push API позволяет доставлять уведомления даже при закрытом приложении.
Загрузка файлов через presigned URLs
Файлы не идут через WebSocket — сначала загружаются на S3/MinIO, затем URL передаётся в сообщении:
async function sendFile(roomId: string, file: File) {
// Загрузка через presigned URL
const { uploadUrl, fileUrl } = await api.post('/chat/upload-url', {
filename: file.name,
mimeType: file.type,
size: file.size,
});
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
const clientId = useChatStore.getState().addOptimistic(roomId, file.name);
socket.emit('message:send', {
roomId,
type: 'file',
content: file.name,
clientId,
attachment: { url: fileUrl, name: file.name, size: file.size, mimeType: file.type },
}, (response) => {
if (response.ok) {
useChatStore.getState().confirmMessage(clientId, response.message!);
}
});
}
Сравнение WebSocket, SSE и Long Polling
| Критерий | WebSocket (Socket.IO) | SSE | Long Polling |
|---|---|---|---|
| Задержка | < 50 мс | 200–500 мс (HTTP push) | 500–2000 мс (зависит от таймаута) |
| Двунаправленность | Да | Нет (только сервер→клиент) | Да (но с задержкой) |
| Поддержка браузеров | Все современные | Все, кроме IE | Все |
| Сложность | Средняя (нужен менеджер каналов) | Низкая (один endpoint) | Низкая |
| Масштабируемость | Высокая (Redis pub/sub) | Средняя (требует балансировки) | Низкая (соединения блокируются) |
WebSocket даёт минимальную задержку и полную гибкость. Для чата это выбор номер один. В одном из проектов клиент жаловался на задержки при 500 одновременных операторах. Мы перешли с long-polling на WebSocket, и время отклика снизилось с 2 секунд до 50 мс.
Что входит в работу
Каждый проект завершается передачей:
- Исходный код серверной и клиентской частей (TypeScript, React, Zustand).
- Docker Compose-файлы для быстрого развёртывания.
- Документация всех событий и API.
- Инструкция по настройке и деплою.
- Техническая поддержка в течение 30 дней после сдачи.
- Обучение команды заказчика работе с системой.
Процесс работы и сроки
Этапы разработки
- Аналитика — выбор протокола, оценка нагрузки, профилирование.
- Проектирование — схема БД, комнаты, события, схема API.
- Реализация — серверный и клиентский код, интеграция с хранилищем.
- Тестирование — нагрузочное тестирование (k6), проверка reconnect, edge-кейсы (пустые комнаты, вложения).
- Деплой — настройка Docker, Nginx, Redis, Web Push subscriptions.
Типичные ошибки и их решение
| Ошибка | Решение |
|---|---|
| Отсутствие реконнекта | Использовать автоматическое переподключение Socket.IO с восстановлением комнат |
| No throttle typing indicator | Debounce с интервалом 2 с |
| Полная загрузка истории | Cursor-based пагинация с лимитом 50 |
| Синхронная загрузка файлов | Presigned URLs и параллельная отправка |
Результат и стоимость
- Исходный код серверной и клиентской частей.
- Docker Compose для быстрого запуска.
- Документация API (событий).
- Инструкция по развёртыванию.
- Техническая поддержка 30 дней после сдачи.
Базовый чат (текст, история, присутствие) — 5–7 дней. Полная реализация с файлами, push-уведомлениями, read receipts и поиском — 2–3 недели. Стоимость разработки базового чата начинается от 150 000 рублей, полная версия — от 300 000 рублей. Обращайтесь для точной оценки — мы подготовим смету за 1–2 дня. Закажите разработку чата с гарантией качества — свяжитесь с нами, чтобы обсудить ваш проект.







