WebSocket, Web Workers и высокоскоростной поток данных: как спасти фронтенд от фризов
В своей практике я постоянно сталкиваюсь с высокоскоростными потоками данных. На последней позиции это была лента событий в SOC‑системе. Но это могут быть и котировки в торговом терминале, данные в системах мониторинга, GPS‑координаты курьеров на карте и так далее. Пакеты летят быстро, плотно и без остановки.
Что делать и как эти потоки обработать? Как делать точно НЕ надо? Открывать нативный new WebSocket(), вешать колбэк на onmessage, парсить прилетевший JSON и пушить его в стейт (Redux, Zustand или просто в useState). Локально на тестовых трех пакетах в секунду все работает красиво. Проблемы начинаются в проде.
В чем проблема?
В реальной нагрузке частота сокет‑сообщений легко может быть 50–100 пакетов в секунду. Пакеты мелкие, это небольшие JSON‑строки размером в 200–300 байт. Суммарный трафик в секунду килобайты. Но дело не в размере.
JavaScript, как мы помним, однопоточен. Наш единственный Main Thread и так уже загружен выше крыши: считает бизнес‑логику, крутит движок реактивности (диффинг, виртуальный DOM), отрабатывает скролл и клики пользователя. И тут в этот же поток с огромной скоростью начинают прилетать пакеты. Браузер вынужден 100 раз в секунду вызывать JSON.parse(), выделять память, сдвигать массивы и перерисовывать DOM.
В итоге Main Thread встает колом. Если открыть вкладку Performance в Chrome DevTools под такой нагрузкой, мы увидим классическую картину:
Scripting сожрал 90% CPU (вся шкала забита сплошным желтым цветом).
Консоль пестрит предупреждениями Long Task и [Violation].
Метрика Total Blocking Time (TBT) зашкаливает.
Плавные 60 FPS превращаются в рваные 15–20 кадров.
Интерфейс просто застывает. Приложение не реагирует на клики, селекторы переключаются с задержкой, телефон пользователя начинает дико греться и жрать батарею. И никакие useMemo, useCallback или батчинг стейтов тут не помогут — поток просто захлебывается на этапе парсинга.
Решение тут одно — убирать вот это вот всё, включая парсинг из главного потока. Для этого выносят WebSocket и буферизацию в Web Worker.
Как это устроено.
Раз JS однопоточен, надо дать ему параллельный поток. Web Worker — это, по сути, изолированный поток ОС. У него свой Event Loop, своя память, и он крутится на отдельном ядре процессора, вообще не мешая интерфейсу рендериться и ловить клики.
Для WebSocket с реальными данными нужен или про тариф (CoinGecko), или подключаться со своим аккаунтом (Binance), поэтому я использовал wss://echo.websocket.org для симуляции. На графике отображаются не реальные цены, а колебания вокруг средней за прошедшие сутки
Логика конвейера получается следующая:
1. Сначала React‑компонент делает REST‑запрос к CoinGecko, качает массив за сутки и считает среднюю цену монеты (чтобы график не стартовал с потолка).
2. Эта цена падает в воркер как точка отсчета.
3. Воркер открывает сокет‑соединение и начинает крутить волатильность относительно живого курса, разгружая Main Thread от парсинга строк и манипуляций с массивами.
Вот рабочий код фонового воркера crypto.worker.ts:
interface StreamPoint {
timestamp: number;
price: number;
}
let tradeData: StreamPoint[] = [];
let socket: WebSocket | null = null;
let pingInterval: ReturnType<typeof setInterval> | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null; // Таймер реконнекта
const MAX_POINTS = 150;
let currentPrice = 0;
let activeCoin = "btcusdt";
const connectWebSocket = () => {
if (socket) {
try {
socket.close();
} catch {}
}
if (pingInterval) clearInterval(pingInterval);
if (reconnectTimeout) clearTimeout(reconnectTimeout);
const finalUrl = "wss://echo.websocket.org";
try {
socket = new WebSocket(finalUrl);
console.log("[Worker] Создано сокет-соединение:", finalUrl);
} catch (urlErr) {
console.error("[Worker] Ошибка создания сокета:", urlErr);
scheduleReconnect();
return;
}
socket.onopen = () => {
console.log("[Worker] Соединение успешно открыто для:", activeCoin);
// Частота до 500мс - чтобы сервер не банил за спам
pingInterval = setInterval(() => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send("ping");
}
}, 500);
};
socket.onmessage = () => {
const volatility = (Math.random() - 0.5) * 2; // [-1, 1]
let multiplier = 0.0005; // BTC
if (activeCoin === "ethusdt") multiplier = 0.0008;
else if (activeCoin === "solusdt") multiplier = 0.0018;
const priceChange = currentPrice * volatility * multiplier;
currentPrice = currentPrice + priceChange;
currentPrice = Math.round(currentPrice * 100) / 100;
const timestamp = Date.now();
tradeData.push({ timestamp, price: currentPrice });
if (tradeData.length > MAX_POINTS) {
tradeData.shift();
}
self.postMessage({ type: "DATA_UPDATE", payload: tradeData });
};
// ЕСЛИ СЕРВЕР ЗАКРЫЛ СОЕДИНЕНИЕ — АВТОМАТИЧЕСКИ ПЕРЕПОДКЛЮЧАЕМСЯ
socket.onclose = (e) => {
console.warn(
"[Worker] WebSocket закрыт сервером. Код:",
e.code,
"Запускаем авто-реконнект...",
);
scheduleReconnect();
};
socket.onerror = (err) => {
console.error("[Worker] Ошибка сокета:", err);
scheduleReconnect();
};
};
const scheduleReconnect = () => {
if (reconnectTimeout) clearTimeout(reconnectTimeout);
// Через 2 секунды пробуем подключиться заново
reconnectTimeout = setTimeout(() => {
connectWebSocket();
}, 2000);
};
self.onmessage = (
event: MessageEvent<{
command: string;
payload?: { coinSymbol: string; basePrice: number };
}>,
) => {
const { command, payload } = event.data;
if (command === "START_STREAM" && payload) {
activeCoin = payload.coinSymbol.toLowerCase();
// Если цена еще не задана или мы переключили монету, берем свежую базовую цену
if (currentPrice === 0 || tradeData.length === 0) {
currentPrice = payload.basePrice;
}
tradeData = [];
connectWebSocket(); // Запуск
}
if (command === "STOP_STREAM") {
if (socket) {
try {
socket.close();
} catch {}
socket = null;
}
if (pingInterval) clearInterval(pingInterval);
if (reconnectTimeout) clearTimeout(reconnectTimeout);
tradeData = [];
currentPrice = 0;
}
};
Стыкуем воркер с React 19 без каскадных рендеров
В React 19 теперь строгий контроль каскадных рендеров — нельзя использовать setState внутри эффектов. Линтер сразу выдаст ошибку.
Поэтому я разделил данные на два русла. Массив точек для графика хранится в мутабельном useRef (React о нем даже не знает), а наверх для текстового интерфейса выдаются только легкие точечные стейты (цена и дельта).
Среднюю цену за прошлые сутки мы считаем на лету из REST‑хука и оборачиваем в useMemo, чтобы не пересчитывать на каждый тик. А проблему «холодного старта» сервера в Next.js (когда реф контейнера еще null, а ResizeObserver уже нужен) решаем через Callback Ref.
Вот как выглядит интеграция:
"use client";
import { useState, useRef, useEffect, JSX, useMemo, useCallback } from "react";
import { useCryptoChartData } from "../../crypto-chart";
interface StreamPoint {
timestamp: number;
price: number;
}
const COIN_MAP: Record<string, string> = {
btcusdt: "bitcoin",
ethusdt: "ethereum",
solusdt: "solana",
};
const padding = { top: 40, right: 20, bottom: 40, left: 70 };
/**
* StreamChart - интерактивный компонент для отображения криптовалютных данных в реальном времени с использованием наложенных слоёв canvas-элементов и web worker.
*
*/
export default function StreamChart({
coinId = "btcusdt",
}: {
coinId?: string;
}): JSX.Element {
const bgCanvasRef = useRef<HTMLCanvasElement>(null);
const lineCanvasRef = useRef<HTMLCanvasElement>(null);
const uiCanvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker | null>(null);
const dataRef = useRef<StreamPoint[]>([]);
const [dimensions, setDimensions] = useState({ width: 700, height: 350 });
const [hoveredIdx, setHoveredIdx] = useState<number>(-1);
const [hoveredPrice, setHoveredPrice] = useState<number | null>(null);
const [tooltipX, setTooltipX] = useState<number>(0);
// ЛАЙВ-СЧЕТЧИК
const [livePrice, setLivePrice] = useState<number | null>(null);
const [priceDelta, setPriceDelta] = useState<number>(0); //
/**
* Следит за изменением размеров контейнера и обновляет `dimensions`.
*/
const containerRefCallback = useCallback((node: HTMLDivElement | null) => {
if (!node) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
setDimensions({ width, height: Math.max(250, width * 0.5) });
}
});
observer.observe(node);
return () => observer.disconnect();
}, []);
const chartWidth = dimensions.width - padding.left - padding.right;
const chartHeight = dimensions.height - padding.top - padding.bottom;
// Очистка реф при смене тикера
useEffect(() => {
dataRef.current = [];
}, [coinId]);
/**
* Загружает исторические данные за 1 день из CoinGecko API.
*/
const coinGeckoSlug = COIN_MAP[coinId] || "bitcoin";
const { data: restData, isLoading: isRestLoading } = useCryptoChartData(
coinGeckoSlug,
1,
);
/**
* Вычисляет среднюю цену за день на основе исторических данных.
*/
const avgPrice = useMemo(() => {
if (!restData || restData.length === 0) return 0;
const sum = restData.reduce((acc, point) => acc + point.price, 0);
return Math.round((sum / restData.length) * 100) / 100;
}, [restData]);
/**
* Инициализирует Web Worker для потоковой передачи данных.
*/
useEffect(() => {
if (isRestLoading || avgPrice === 0) return;
const worker = new Worker(
new URL("../lib/crypto.worker.ts", import.meta.url),
{ type: "module" },
);
workerRef.current = worker;
worker.onmessage = (e) => {
if (e.data.type === "DATA_UPDATE") {
const points = e.data.payload;
dataRef.current = points;
// Обновляем метрики
if (points && points.length > 0) {
const latest = points[points.length - 1].price;
setLivePrice(latest);
if (avgPrice > 0) {
const delta = ((latest - avgPrice) / avgPrice) * 100;
setPriceDelta(Math.round(delta * 100) / 100);
}
}
}
};
// Передаем в payload имя тикера и базовую цену
worker.postMessage({
command: "START_STREAM",
payload: { coinSymbol: coinId, basePrice: avgPrice },
});
return () => {
worker.postMessage({ command: "STOP_STREAM" });
worker.terminate();
};
}, [coinId, avgPrice, isRestLoading]);
/**
* Подготавливает все 3 canvas'а: задаёт размеры с учётом `devicePixelRatio`.
*/
useEffect(() => {
const bg = bgCanvasRef.current;
const line = lineCanvasRef.current;
const ui = uiCanvasRef.current;
if (!bg || !line || !ui) return;
const dpr = window.devicePixelRatio || 1;
[bg, line, ui].forEach((canvas) => {
canvas.width = dimensions.width * dpr;
canvas.height = dimensions.height * dpr;
canvas.style.width = `${dimensions.width}px`;
canvas.style.height = `${dimensions.height}px`;
});
}, [dimensions]);
//
// Бесконечный цикл анимации requestAnimationFrame (Game Loop)
useEffect(() => {
let animationFrameId: number;
const renderLoop = () => {
const bgCanvas = bgCanvasRef.current;
const lineCanvas = lineCanvasRef.current;
const uiCanvas = uiCanvasRef.current;
const points = dataRef.current;
if (!bgCanvas || !lineCanvas || !uiCanvas || points.length < 2) {
animationFrameId = requestAnimationFrame(renderLoop);
return;
}
const bgCtx = bgCanvas.getContext("2d");
const lineCtx = lineCanvas.getContext("2d");
const uiCtx = uiCanvas.getContext("2d");
if (!bgCtx || !lineCtx || !uiCtx) return;
const dpr = window.devicePixelRatio || 1;
const isDarkMode = document.documentElement.classList.contains("dark");
// Очищаем слои
bgCtx.setTransform(1, 0, 0, 1, 0, 0);
bgCtx.clearRect(0, 0, bgCanvas.width, bgCanvas.height);
lineCtx.setTransform(1, 0, 0, 1, 0, 0);
lineCtx.clearRect(0, 0, lineCanvas.width, lineCanvas.height);
uiCtx.setTransform(1, 0, 0, 1, 0, 0);
uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
//
bgCtx.scale(dpr, dpr);
lineCtx.scale(dpr, dpr);
uiCtx.scale(dpr, dpr);
// Слой 1: Статичная сетка
bgCtx.strokeStyle = isDarkMode
? "rgba(255, 255, 255, 0.06)"
: "rgba(161, 161, 170, 0.12)";
bgCtx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartHeight / 4) * i;
bgCtx.beginPath();
bgCtx.moveTo(padding.left, y);
bgCtx.lineTo(dimensions.width - padding.right, y);
bgCtx.stroke();
}
// Рассчитываем экстремумы цен для текущего кадра
const prices = points.map((p) => p.price);
const rMax = Math.max(...prices);
const rMin = Math.min(...prices);
const rRange = rMax - rMin || 1;
const maxPrice = rMax + rRange * 0.05;
const minPrice = Math.max(0, rMin - rRange * 0.05);
const priceRange = maxPrice - minPrice || 1;
const stepX = chartWidth / (points.length - 1 || 1);
const getX = (idx: number) => padding.left + idx * stepX;
const getY = (price: number) =>
dimensions.height -
padding.bottom -
((price - minPrice) / priceRange) * chartHeight;
// ЦЕНЫ НА ОСИ Y
lineCtx.fillStyle = isDarkMode ? "#a1a1aa" : "#71717a";
lineCtx.font = "10px monospace";
lineCtx.textAlign = "right";
lineCtx.textBaseline = "middle";
for (let i = 0; i <= 4; i++) {
const currentPrice = minPrice + (priceRange / 4) * i;
const y = getY(currentPrice);
lineCtx.fillText(
`$${Math.round(currentPrice).toLocaleString()}`,
padding.left - 10,
y,
);
}
// Слой 2: Линия тренда и градиент под ней
lineCtx.save();
lineCtx.beginPath();
lineCtx.rect(padding.left, padding.top, chartWidth, chartHeight);
lineCtx.clip();
lineCtx.beginPath();
points.forEach((p, idx) => lineCtx.lineTo(getX(idx), getY(p.price)));
lineCtx.strokeStyle = isDarkMode ? "#22c55e" : "#16a34a";
lineCtx.lineWidth = 2.5;
lineCtx.stroke();
lineCtx.lineTo(
getX(points.length - 1),
dimensions.height - padding.bottom,
);
lineCtx.lineTo(getX(0), dimensions.height - padding.bottom);
lineCtx.closePath();
const grad = lineCtx.createLinearGradient(
0,
padding.top,
0,
dimensions.height - padding.bottom,
);
grad.addColorStop(
0,
isDarkMode ? "rgba(34, 197, 94, 0.12)" : "rgba(22, 163, 74, 0.15)",
);
grad.addColorStop(1, "rgba(22, 163, 74, 0.0)");
lineCtx.fillStyle = grad;
lineCtx.fill();
lineCtx.restore();
// Слой 3: Тултип-прицел
if (hoveredIdx >= 0 && points[hoveredIdx]) {
const targetX = getX(hoveredIdx);
const targetY = getY(points[hoveredIdx].price);
uiCtx.strokeStyle = isDarkMode ? "#22c55e" : "#16a34a";
uiCtx.lineWidth = 1;
uiCtx.beginPath();
uiCtx.moveTo(targetX, padding.top);
uiCtx.lineTo(targetX, dimensions.height - padding.bottom);
uiCtx.stroke();
uiCtx.beginPath();
uiCtx.arc(targetX, targetY, 5, 0, 2 * Math.PI);
uiCtx.fillStyle = isDarkMode ? "#22c55e" : "#16a34a";
uiCtx.fill();
}
animationFrameId = requestAnimationFrame(renderLoop);
};
animationFrameId = requestAnimationFrame(renderLoop);
return () => cancelAnimationFrame(animationFrameId);
}, [dimensions, chartHeight, chartWidth, hoveredIdx]);
// Движение мыши/тача
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = uiCanvasRef.current;
const points = dataRef.current;
if (!canvas || points.length < 2) return;
const rect = canvas.getBoundingClientRect();
const canvasWidth = rect.width;
const mouseX = e.clientX - rect.left;
if (mouseX >= padding.left && mouseX <= canvasWidth - padding.right) {
const stepX = chartWidth / (points.length - 1 || 1);
const idx = Math.round((mouseX - padding.left) / stepX);
if (idx >= 0 && idx < points.length) {
setHoveredIdx(idx);
setHoveredPrice(points[idx].price);
setTooltipX(padding.left + idx * stepX);
}
} else {
setHoveredIdx(-1);
setHoveredPrice(null);
}
};
return (
<div
ref={containerRefCallback}
className="relative bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-3xl shadow-sm w-full select-none"
>
{isRestLoading && (
<div className="absolute inset-0 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xs flex items-center justify-center rounded-3xl z-50 text-zinc-400 font-semibold text-sm animate-pulse">
Syncing with CoinGecko Market Rates...
</div>
)}
{livePrice && (
<div className="flex items-baseline gap-3 px-2">
<span className="text-sm sm:text-base font-bold px-2 py-0.5 rounded-lg font-mono flex items-center gap-0.5 bg-zinc-500/10 text-zinc-800 dark:text-zinc-400">
$
{livePrice.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
<span
className={`text-sm sm:text-base font-bold px-2 py-0.5 rounded-lg font-mono flex items-center gap-0.5 ${
priceDelta >= 0
? "bg-green-500/10 text-green-500 dark:text-green-400"
: "bg-red-500/10 text-red-500 dark:text-red-400"
}`}
>
{priceDelta >= 0 ? "▲" : "▼"} {priceDelta >= 0 ? "+" : ""}
{priceDelta}%
</span>
<span className="text-xs text-zinc-400 font-medium">
vs day average (${avgPrice.toLocaleString()})
</span>
</div>
)}
{/* Оболочка канвасов */}
<div
className="relative w-full overflow-hidden"
style={{ height: dimensions.height }}
>
{/* СЛОЙ 1: Сетка */}
<canvas
ref={bgCanvasRef}
className="absolute top-0 left-0 pointer-events-none z-10"
style={{ width: dimensions.width, height: dimensions.height }}
/>
{/* СЛОЙ 2: Линия тренда */}
<canvas
ref={lineCanvasRef}
className="absolute top-0 left-0 pointer-events-none z-20"
style={{ width: dimensions.width, height: dimensions.height }}
/>
{/* СЛОЙ 3: Интерактивный UI */}
<canvas
ref={uiCanvasRef}
style={{ width: dimensions.width, height: dimensions.height }}
onMouseMove={handleMouseMove}
onMouseLeave={() => {
setHoveredIdx(-1);
setHoveredPrice(null);
}}
className="absolute top-0 left-0 cursor-crosshair z-30 pointer-events-auto"
/>
{/* Тултип */}
{hoveredIdx >= 0 && hoveredPrice !== null && (
<div
style={{
position: "absolute",
left:
tooltipX + 15 > dimensions.width - 130
? tooltipX - 135
: tooltipX + 15,
pointerEvents: "none",
}}
className="z-40 bg-zinc-950/95 text-white p-2 rounded-xl border border-zinc-800 text-xs font-mono"
>
${hoveredPrice.toLocaleString()} Live Streaming from Test WebSocket
(Web Worker Layered Canvas)
</div>
)}
</div>
</div>
);
}
Что в итоге показал профайлер DevTools?
Я собрал продакшн‑билд проекта (Next.js минимизирует бандлы, делает Tree Shaking) и прогнал тесты во вкладке Performance в режиме инкогнито.
Результаты получились:
1. Нагрузка на CPU Scripting в главном потоке около 8%. Потому что сокет‑сообщения не дергают React.
2. Метрика Total Blocking Time (TBT) — 10 мс. Ни одной Long Task длиннее 50 мс.
3. Кадровая частота зафиксировалась на 60 FPS на десктопе и выдает честные 120 FPS на смартфонах.
Плюс я внедрил ленивую загрузку через next/dynamic с правильным Layout Skeleton (заглушкой карточки), что позволило полностью ликвидировать сдвиги макета — метрика CLS упала до 0.01, а LCP составил всего 1.5 секунд на Slow 4G сетях.
Посмотреть на живой пример реализации можно в моем репозитории на GitHub:
https://github.com/ochuksin/coin‑pulse