В современных интерактивных веб-приложениях классическая модель клиент-серверного взаимодействия на базе периодического опроса (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 и ограничивать размер оффлайн-очереди.