В своей практике я постоянно сталкиваюсь с высокоскоростными потоками данных. На последней позиции это была лента событий в 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