Я відвантажив дві WebSocket-важкі продакшн-системи: тикетинг-платформу E7 (оновлення карти місць у реальному часі під час високонавантажених продажів) та командний інструмент із живою присутністю та месенджером. Обидва навчили різному про архітектуру реального часу.
Цей пост покриває патерни, що пережили продакшн — не підручникові патерни, а ті, що реально працюють, коли 3,000 людей борються за одні й ті самі місця або 200 членів команди потребують миттєвої доставки повідомлень.
Патерн 1: Модель кімнат
Обидві системи використовують кімнати. Кімната — логічне групування WebSocket-з'єднань, що поділяють стан.
Тикетинг: Одна кімната на продаж заходу. Коли користувач відкриває карту місць для Заходу #42, він вступає в кімнату event:42. Кожне блокування, розблокування та покупка місця розсилається всім у кімнаті.
Чат: Одна кімната на канал. Коли користувач відкриває #engineering, він вступає в кімнату channel:engineering. Кожне повідомлення, редагування та індикатор набору розсилається всім у кімнаті.
Модель кімнат вирішує проблему fan-out: ти надсилаєш оновлення лише тим з'єднанням, яким це потрібно.
// Спрощене управління кімнатами
const rooms = new Map<string, Set<WebSocket>>()
function joinRoom(ws: WebSocket, roomId: string) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set())
rooms.get(roomId)!.add(ws)
}
function broadcastToRoom(roomId: string, message: object, exclude?: WebSocket) {
const room = rooms.get(roomId)
if (!room) return
const payload = JSON.stringify(message)
for (const ws of room) {
if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
ws.send(payload)
}
}
}
Урок: Тримай кімнати маленькими. E7 мав заходи з 3,000 одночасних користувачів в одній кімнаті. У такому масштабі одне блокування місця генерує 3,000 вихідних повідомлень. Ми оптимізували батчингом: замість розсилки кожного блокування окремо, ми батчимо оновлення кожні 200ms і шлемо одне повідомлення з усіма змінами за це вікно. Це скоротило вихідні повідомлення на ~80% під час пікових продажів.
Патерн 2: Оптимістичні оновлення з серверною звіркою
Обидві системи використовують однаковий клієнтський патерн: застосувати зміну негайно в UI, надіслати на сервер, звірити, якщо сервер не погоджується.
Блокування місця:
- Користувач тапає місце → UI показує його «вашим» (зелений) негайно
- Клієнт шле
{ type: "lock_seat", seatId: "A-15" }через WebSocket - Сервер перевіряє: A-15 доступне? Якщо так → підтверджує, розсилає. Якщо ні → шле відмову
- При відмові → клієнт повертає місце в «доступні» з хаптік-вібрацією
Повідомлення чату:
- Користувач шле повідомлення → UI рендерить негайно з індикатором «надсилання»
- Клієнт шле через WebSocket
- Сервер зберігає, призначає ID та timestamp, розсилає
- Клієнт отримує підтверджену версію та оновлює локальне повідомлення
Ключовий інсайт: оптимістичні оновлення ховають затримку, серверна звірка забезпечує коректність.
Що пішло не так спочатку: У тикетингу не було таймауту на оптимістичне блокування. Користувач тапав місце, WebSocket-повідомлення тихо провалювалося, і місце лишалося зеленим на екрані вічно. Фікс: кожне оптимістичне оновлення має 3-секундний таймаут. Якщо підтвердження від сервера не приходить — клієнт відкочує і показує банер «з'єднання втрачено».
Патерн 3: Heartbeat та перепідключення
WebSocket-з'єднання вмирають тихо.
Обидві системи використовують heartbeat з серверним ping кожні 30 секунд та клієнтське перепідключення з експоненціальним backoff і jitter:
function connect() {
const ws = new WebSocket(url)
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000)
const jitter = delay * 0.5 * Math.random()
setTimeout(connect, delay + jitter)
reconnectAttempts++
}
ws.onopen = () => {
reconnectAttempts = 0
rejoinRooms()
}
}
Jitter критичний. Без нього, якщо сервер перезавантажується і 3,000 клієнтів перепідключаються одночасно — вони всі ретраять з однаковими інтервалами, створюючи thundering herd, що роняє сервер знову.
Патерн 4: Присутність
Чат показує, хто онлайн і хто набирає.
Індикатор набору: Клієнт шле { type: "typing" }, коли користувач починає набирати. Без події «перестав набирати». Замість цього індикатор на клієнті має 3-секундний таймаут. Якщо нова typing-подія не приходить — індикатор зникає. Простіше і надійніше.
Підводний камінь: Присутність потребує єдиного джерела правди для «хто підключений». Якщо запускаєш кілька WebSocket-серверів — потрібне спільне сховище (Redis pub/sub) для агрегації.
Для E7 — один WebSocket-сервер (трафік пульсуючий). Для чату — Redis pub/sub для fan-out через 3 інстанси.
Патерн 5: Порядок повідомлень та дедуплікація
Повідомлення можуть приходити не в порядку або дублюватися.
Порядок: Кожне повідомлення отримує серверний порядковий номер. Клієнт рендерить у порядку номерів, не в порядку прибуття.
Дедуплікація: Кожне клієнтське повідомлення включає idempotencyKey (UUID). Сервер перевіряє ключ перед обробкою.
Тикетинг-система не потребує порядку (стан місць ідемпотентний), але критично потребує дедуплікації (подвійний тап не повинен створювати два запити блокування).
Патерн 6: Грейсфул деградація
WebSocket падають. Файрволи блокують. Корпоративні проксі зрізають Upgrade-заголовок.
Тикетинг: Фолбек на HTTP polling кожні 2 секунди. Близько 3% користувачів потрапляють на polling — переважно корпоративні мережі.
Чат: Фолбек на long-polling. Менш ефективний, але працює через будь-який HTTP-проксі.
MVP-шорткат: Для MVP E7 ми відвантажили polling-only. Жодних WebSocket взагалі. Карта місць оновлювалася кожні 3 секунди через HTTP. Цього вистачило на перші 3 місяці. WebSocket додали, коли розміри заходів зросли і 3-секундні затримки почали спричиняти подвійні бронювання.
Реальний трейдоф: polling — рішення рівня MVP. WebSocket — рішення рівня масштабу. Не будуй real-time інфраструктуру до того, як вона потрібна.
Що б зробив інакше
Використав би Server-Sent Events (SSE) для тикетингу замість WebSocket. Карта місць — однонаправлений потік: сервер пушить оновлення клієнтам. Клієнти шлють блокування через звичайні HTTP POST. SSE простіше (немає upgrade handshake, працює через проксі, автоматичне перепідключення вбудоване в браузер).
Серйозніше розглянув би Socket.IO для чату. Уникав через розмір бандла та упередженість «просто використовуй ws». Але Socket.IO обробляє перепідключення, кімнати та мультиплексування з коробки — все, що я побудував вручну.
Збираєш щось, що потребує оновлень у реальному часі? Давай розберемося з правильною архітектурою — іноді це WebSocket, іноді SSE, іноді просто polling із 2-секундним інтервалом.