Реалізація Live Chat на сайті
Розробка чату реального часу — завдання, де архітектурні рішення вирішують усе. Наприклад, вибір REST замість WebSocket при 100+ одночасних користувачах призводить до затримок і надмірного трафіку. Ми реалізували понад 20 чат-систем для різних проєктів — від внутрішньої підтримки до масштабних продакшн-рішень із навантаженням до 10 000 з'єднань на інстансі. Чат реального часу не лише покращує користувацький досвід, але й знижує навантаження на службу підтримки, дозволяючи обробляти запити в 3-4 рази швидше. Ми надаємо гарантію на всі роботи та технічну підтримку протягом 30 днів.
Повноцінний чат — це не просто 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. Також WebSocket забезпечує потокобезпечність і атомарність операцій.
Структура даних повідомлень
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 тижні. Вартість розробки визначається після аналізу вимог — ми підготуємо кошторис за 1–2 дні. Звертайтеся для точної оцінки — пишіть, щоб обговорити ваш проєкт. Ми оцінимо проєкт безкоштовно.







