У сучасних інтерактивних вебзастосунках класична модель клієнт-серверної взаємодії на базі періодичного опитування (Short Polling або Long Polling) не відповідає суворим вимогам щодо затримки (Latency) та мережевого навантаження:
Для організації потокового передавання даних у реальному часі застосовуються три основні мережеві протоколи:
У React-застосунках ключовою архітектурною складністю є інтеграція довгоживучих мережевих сокетів із декларативним життєвим циклом компонентів: запобігання витокам пам'яті, коректна обробка монтування та розмонтування в StrictMode React 19, усунення «напіввідкритих» сокетів (Zombie Connections) та гарантована доставка повідомлень за умов нестабільної мережі.
Мережевий сокет — це імперативний зовнішній ресурс із життєвим циклом, незалежним від рендерів React. Створення new WebSocket() безпосередньо в тілі компонента призводить до лавиноподібного відкриття сотень паралельних з'єднань при кожній зміні стану. Управління сокетом має бути винесене в ізольований хук або синглтон-менеджер зі скінченним автоматом станів, буфером офлайн-черги та захистом від перевантаження бекенду (Full Jitter Backoff).
Мережеве з'єднання в React слід розглядати як зовнішній спільний ресурс зі скінченним автоматом станів (State Machine): CONNECTING, OPEN, CLOSING, CLOSED.
┌────────────────────────────────────────────────────────────────────────┐
│ ПРОТОКОЛИ ПЕРЕДАЧІ ДАНИХ У REAL-TIME │
│ │
│ 1. WebSockets (TCP): │
│ Client <══════════════ Двонаправлений обмін (JSON/Binary) ══════════> Server│
│ • Плюси: низькі накладні витрати фреймів (2-10 байт), повний дуплекс│
│ • Мінуси: Head-of-Line Blocking на рівні TCP при втраті пакетів │
│ │
│ 2. Server-Sent Events (HTTP/2 або HTTP/3): │
│ Client <────────────────── Потік подій (text/event-stream) ───────── Server│
│ Client ─── HTTP POST / RPC ───> (окремий канал для команд на сервер)│
│ • Плюси: нативний автореконнект, мультиплексування в HTTP/2 │
│ • Мінуси: тільки текст (UTF-8), однонаправлений від сервера │
│ │
│ 3. WebTransport (QUIC / UDP): │
│ Client <─ ─ ─ ─ ─ Ненадійні датаграми (Datagrams / UDP) ─ ─ ─ ─ ─ ─> Server│
│ Client <═══════ Мультиплексовані надійні потоки (Streams) ══════════> Server│
│ • Плюси: нульовий Head-of-Line Blocking між потоками, наднизька │
│ затримка для відео/телеметрії/ігор │
└────────────────────────────────────────────────────────────────────────┘Для надійної роботи в production архітектура хука useWebSocket спирається на три системні інваріанти:
OPEN.Створення екземпляра WebSocket в ізольованому рефі, прив'язка типізованих слухачів та запуск таймера Heartbeat.
Кожні 30 секунд відправляється ping-повідомлення; якщо сервер не повернув pong за 5 секунд, сокет примусово розривається.
Спроби виклику sendMessage під час обриву мережі складаються в кільцевий буфер з обмеженням розміру maxQueueSize.
Розрахунок випадкового інтервалу повторної спроби для рівномірного розподілу навантаження на шлюз, що відновлюється.
При переході сокета в OPEN скидається лічильник спроб і виконується пакетна відправка накопичених повідомлень.
У якому сценарії архітектурно обґрунтовано обрати Server-Sent Events (SSE) замість повнодуплексного WebSockets?
ArrayBuffer для мінімізації навантаження на збирач сміття V8.Нижче наведено повну реалізацію хука без використання типу any, з підтримкою суворої типізації вхідних і вихідних даних, алгоритму Full Jitter, Heartbeat Ping-Pong та офлайн-черги:
'use client';
import * as React from 'react';
export type WebSocketStatus = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED';
export interface UseWebSocketOptions<TIncoming, TOutgoing> {
url: string;
protocols?: string | string[];
/** Базова затримка реконнекту в мс (за замовчуванням 1000) */
baseReconnectDelayMs?: number;
/** Максимальна затримка реконнекту в мс (за замовчуванням 30000) */
maxReconnectDelayMs?: number;
/** Інтервал відправлення Heartbeat пінгів у мс (за замовчуванням 30000) */
heartbeatIntervalMs?: number;
/** Таймаут відповіді на Heartbeat у мс (за замовчуванням 5000) */
heartbeatTimeoutMs?: number;
/** Повідомлення для пінгу (за замовчуванням { type: 'ping' }) */
pingMessage?: TOutgoing;
/** Максимальний розмір офлайн-черги повідомлень */
maxQueueSize?: number;
/** Автоматичне підключення при монтуванні */
autoConnect?: boolean;
onMessage?: (message: TIncoming) => void;
onError?: (event: Event) => void;
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
}
export interface UseWebSocketReturn<TIncoming, TOutgoing> {
status: WebSocketStatus;
sendMessage: (data: TOutgoing) => boolean;
lastMessage: TIncoming | null;
connect: () => void;
disconnect: () => void;
}
export function useWebSocket<TIncoming = unknown, TOutgoing = unknown>(
options: UseWebSocketOptions<TIncoming, TOutgoing>
): UseWebSocketReturn<TIncoming, TOutgoing> {
const {
url,
protocols,
baseReconnectDelayMs = 1000,
maxReconnectDelayMs = 30000,
heartbeatIntervalMs = 30000,
heartbeatTimeoutMs = 5000,
pingMessage = { type: 'ping' } as unknown as TOutgoing,
maxQueueSize = 100,
autoConnect = true,
onMessage,
onError,
onOpen,
onClose,
} = options;
const [status, setStatus] = React.useState<WebSocketStatus>('CLOSED');
const [lastMessage, setLastMessage] = React.useState<TIncoming | null>(null);
// Посилання на інфраструктурні об'єкти для ізоляції від перерендерів
const wsRef = React.useRef<WebSocket | null>(null);
const reconnectAttemptsRef = React.useRef<number>(0);
const reconnectTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatTimerRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
const heartbeatTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const messageQueueRef = React.useRef<string[]>([]);
const isManuallyClosedRef = React.useRef<boolean>(false);
// Сховище стабільних посилань на колбеки
const callbacksRef = React.useRef({ onMessage, onError, onOpen, onClose });
React.useEffect(() => {
callbacksRef.current = { onMessage, onError, onOpen, onClose };
});
const clearTimers = React.useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (heartbeatTimerRef.current) {
clearInterval(heartbeatTimerRef.current);
heartbeatTimerRef.current = null;
}
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
heartbeatTimeoutRef.current = null;
}
}, []);
// Розрахунок затримки за формулою Full Jitter
const calculateBackoff = React.useCallback(
(attempt: number): number => {
const exponentialDelay = baseReconnectDelayMs * Math.pow(2, attempt);
const cappedDelay = Math.min(maxReconnectDelayMs, exponentialDelay);
return Math.floor(Math.random() * cappedDelay);
},
[baseReconnectDelayMs, maxReconnectDelayMs]
);
// Скидання накопичених офлайн-повідомлень у відкритий сокет
const flushQueue = React.useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
while (messageQueueRef.current.length > 0) {
const payload = messageQueueRef.current.shift();
if (payload) {
wsRef.current.send(payload);
}
}
}, []);
// Моніторинг активності сокета через Ping-Pong
const startHeartbeat = React.useCallback(() => {
if (heartbeatTimerRef.current) clearInterval(heartbeatTimerRef.current);
heartbeatTimerRef.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(pingMessage));
// Якщо відповідь не отримана протягом heartbeatTimeoutMs — сокет завис
heartbeatTimeoutRef.current = setTimeout(() => {
console.warn('[WebSocket]: Перевищено таймаут Heartbeat. Примусовий розрив зомбі-сокета...');
wsRef.current?.close(4000, 'Heartbeat Timeout');
}, heartbeatTimeoutMs);
}
}, heartbeatIntervalMs);
}, [heartbeatIntervalMs, heartbeatTimeoutMs, pingMessage]);
const connect = React.useCallback(() => {
clearTimers();
isManuallyClosedRef.current = false;
try {
setStatus('CONNECTING');
const ws = new WebSocket(url, protocols);
wsRef.current = ws;
ws.onopen = (event) => {
setStatus('OPEN');
reconnectAttemptsRef.current = 0;
startHeartbeat();
flushQueue();
callbacksRef.current.onOpen?.(event);
};
ws.onmessage = (event: MessageEvent<string>) => {
// Будь-який вхідний пакет скидає таймаут завислого Heartbeat
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
heartbeatTimeoutRef.current = null;
}
try {
const parsedData = JSON.parse(event.data) as TIncoming;
// Ігноруємо технічні відповіді на ping
if ((parsedData as { type?: string })?.type === 'pong') return;
setLastMessage(parsedData);
callbacksRef.current.onMessage?.(parsedData);
} catch {
setLastMessage(event.data as unknown as TIncoming);
callbacksRef.current.onMessage?.(event.data as unknown as TIncoming);
}
};
ws.onerror = (event) => {
callbacksRef.current.onError?.(event);
};
ws.onclose = (event) => {
clearTimers();
setStatus('CLOSED');
wsRef.current = null;
callbacksRef.current.onClose?.(event);
if (!isManuallyClosedRef.current) {
const delay = calculateBackoff(reconnectAttemptsRef.current);
reconnectAttemptsRef.current += 1;
reconnectTimerRef.current = setTimeout(() => {
connect();
}, delay);
}
};
} catch {
setStatus('CLOSED');
}
}, [url, protocols, clearTimers, startHeartbeat, flushQueue, calculateBackoff]);
const disconnect = React.useCallback(() => {
isManuallyClosedRef.current = true;
clearTimers();
if (wsRef.current) {
setStatus('CLOSING');
wsRef.current.close(1000, 'User Disconnect');
wsRef.current = null;
}
setStatus('CLOSED');
}, [clearTimers]);
const sendMessage = React.useCallback(
(data: TOutgoing): boolean => {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(payload);
return true;
}
// Збереження в офлайн-чергу з витісненням найстаріших записів
if (messageQueueRef.current.length >= maxQueueSize) {
messageQueueRef.current.shift();
}
messageQueueRef.current.push(payload);
return false;
},
[maxQueueSize]
);
React.useEffect(() => {
if (autoConnect) {
connect();
}
return () => {
isManuallyClosedRef.current = true;
clearTimers();
if (wsRef.current) {
wsRef.current.close();
}
};
}, [autoConnect, connect, clearTimers]);
return {
status,
sendMessage,
lastMessage,
connect,
disconnect,
};
}Створення екземпляра WebSocket, навішування типізованих слухачів та запуск таймера Heartbeat.
Кожні 30 секунд клієнт надсилає heartbeat-пінг. Якщо сервер не повернув понг за 5 секунд, сокет розривається.
Спроби виклику sendMessage під час обриву складаються в кільцевий буфер з обмеженням розміру (maxQueueSize).
Розрахунок інтервалу наступної спроби з випадковим зміщенням для захисту бекенду від ефекту Thundering Herd.
Чому при масовому збої на мережевому шлюзі простий алгоритм Exponential Backoff (без рандомізованого джитеру) призводить до падіння сервера?
Нижче представлено компонент біржової стрічки угод і цін активів у реальному часі, що демонструє інтеграцію хука useWebSocket зі строгою типізацією, індикацією статусу з'єднання та обмеженням частоти оновлень:
'use client';
import * as React from 'react';
import { useWebSocket, type WebSocketStatus } from '@/shared/hooks/useWebSocket';
interface PriceTick {
symbol: string;
price: number;
change24h: number;
timestamp: number;
}
interface SubscribeCommand {
action: 'subscribe' | 'unsubscribe';
channel: string;
}
const statusColorMap: Record<WebSocketStatus, string> = {
CONNECTING: 'bg-yellow-500',
OPEN: 'bg-emerald-500',
CLOSING: 'bg-orange-500',
CLOSED: 'bg-red-500',
};
export function CryptoTicker({ symbol }: { symbol: string }) {
const [ticks, setTicks] = React.useState<PriceTick[]>([]);
const { status, sendMessage, lastMessage } = useWebSocket<PriceTick, SubscribeCommand>({
url: 'wss://stream.agenticlab.dev/realtime',
autoConnect: true,
onOpen: () => {
// Підписуємося на канал символу одразу при встановленні з'єднання
sendMessage({ action: 'subscribe', channel: `quotes:${symbol}` });
},
onMessage: (data) => {
if (data && data.symbol === symbol) {
setTicks((prev) => [data, ...prev.slice(0, 19)]);
}
},
});
return (
<div className="p-6 rounded-2xl bg-bg-surface border border-border space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${statusColorMap[status]} animate-pulse`} />
<h3 className="font-semibold text-text-primary text-sm tracking-wide">
{symbol} / USDT Live Stream
</h3>
</div>
<span className="text-xs font-mono text-text-secondary uppercase">{status}</span>
</div>
<div className="text-3xl font-extrabold text-text-primary font-mono">
${lastMessage?.price?.toFixed(2) ?? '---.--'}
</div>
<div className="border-t border-border pt-3">
<div className="text-xs text-text-muted mb-2">Останні тіки (Realtime):</div>
<div className="space-y-1 font-mono text-xs max-h-40 overflow-y-auto">
{ticks.map((tick) => (
<div key={tick.timestamp} className="flex justify-between py-0.5 text-text-secondary">
<span>{new Date(tick.timestamp).toLocaleTimeString()}</span>
<span className={tick.change24h >= 0 ? 'text-emerald-400' : 'text-red-400'}>
${tick.price.toFixed(2)}
</span>
</div>
))}
</div>
</div>
</div>
);
}Оголошення const socket = new WebSocket(...) без ізоляції всередині useEffect призводить до того, що при кожному рендері компонента відкривається нове паралельне мережеве з'єднання. За кілька хвилин застосунок вичерпує пул файлових дескрипторів та портів ОС.
// ❌ АНТИПАТЕРН: Сокет створюється при кожному рендері
function BadComponent() {
const ws = new WebSocket('wss://api.example.com'); // Витік сокетів!
ws.onmessage = (e) => console.log(e.data);
return <div>Realtime</div>;
}
// ✅ ПРАВИЛЬНО: Інкапсуляція в хук із детермінованим очищенням
function GoodComponent() {
const { status, lastMessage } = useWebSocket({ url: 'wss://api.example.com' });
return <div>{lastMessage}</div>;
}Якщо тисячі клієнтів одночасно втрачають зв'язок при перезапуску балансувальника, а в коді використовується сувора формула delay = attempt * 1000, усі клієнти відправлять запит на повторне підключення синхронно секунда в секунду. Бекенд упаде під DDoS-атакою власних клієнтів. Обов'язково використовуйте алгоритм Full Jitter.
Якщо користувач перебуває в офлайні кілька годин, а застосунок продовжує складати вихідні дії в масив queue.push(...) без обмеження розміру, пам'ять вкладки браузера переповниться, спричинивши аварійне закриття процесу вкладки з помилкою Out of Memory. Завжди задавайте параметр maxQueueSize із витісненням найстаріших записів.
У режимі розробки React 19 навмисно монтує, розмонтовує і знову монтує компонент для перевірки очищення сайд-ефектів. Якщо в cleanup-функції сокет не закривається явно через ws.close(), перше з'єднання залишиться висіти у фоні, викликаючи задвоєння повідомлень.
useWebSocket зобов'язаний ізолювати сокет від ререндерів React, застосовувати формулу Full Jitter Backoff, контролювати зомбі-сокети через Heartbeat Ping-Pong та обмежувати розмір офлайн-черги.