Дайджесты новостей
Трёхмерная схема высокопроизводительного потока данных в React с Web Worker, Zero-Copy буфером и мутабельным стором на 100 000 обновлений в секунду.

100 000 обновлений в секунду в React: WebSocket, Web Workers и свой стор

Представь задачу из мира высоконагруженного финтеха или мониторинга: в браузере открыта таблица на 100 000 позиций. По протоколу WebSocket с сервера непрерывно летят котировки — пачками по 5 000 дельт каждые 50 миллисекунд. В сумме это даёт 100 000 обновлений значений полей в секунду. При этом интерфейс должен оставаться отзывчивым: плавная прокрутка 60 FPS, моментальная реакция на клики и никаких фризов.

Если попытаться решить эту задачу стандартными средствами React — положить данные в useState, подключить Redux Toolkit с Immer или Zustand — вкладка браузера зависнет намертво в первую же секунду после установки соединения.

В практическом разборе на Хабре автор rusaleffa препарирует причины мгновенного коллапса стандартного стейт-менеджмента и показывает, как трёхзвенная архитектура с Web Workers спасает производительность интерфейса.

Анатомия коллапса: почему useState и Redux убивают главный поток браузера

В браузере JavaScript исполняется в едином главном потоке (Main Thread). Этот поток делит время процессора между выполнением кода, расчётом стилей и отрисовкой кадров (бюджет — 16.6 мс на кадр для 60 FPS).

Когда в главный поток прилетает пачка из 5 000 JSON-объектов, происходят три события:

  1. Блокировка парсингом: разбор текстового JSON такого объёма через JSON.parse занимает от 30 до 70 мс, намертво блокируя интерфейс.
  2. Паузы сборщика мусора: Redux и React требуют создания новых копий объектов при каждом изменении ({ ...item, price }). Создание 100 000 объектов в секунду перегружает кучу. Сборщик мусора включает Major GC, замораживая поток на сотни миллисекунд.
  3. Каскадный перерендер: изменение глобального состояния заставляет React сверять дерево виртуального DOM по тысячам селекторов.

Бинарный протокол и вынос сетевого парсинга в выделенный Web Worker

Первый шаг — убрать сетевую рутину и парсинг из главного потока, открывая WebSocket внутри фонового потока Dedicated Web Worker.

Второй шаг — отказ от текстового JSON в пользу бинарной упаковки данных через ArrayBuffer и DataView. Каждая дельта пакуется всего в 16 байт: 4 байта на id, 8 байт на price и 4 байта на volume. Сетевой трафик падает в 3–4 раза.

Воркер принимает бинарный поток и пересылает буфер в главный поток:

// worker.js - фоновый поток обработки телеметрии
let socket = null;

self.onmessage = (event) => {
  if (event.data.type === 'CONNECT') {
    socket = new WebSocket(event.data.url);
    socket.binaryType = 'arraybuffer';

    socket.onmessage = (event) => {
      if (event.data instanceof ArrayBuffer) {
        // Передаем буфер в Main Thread по схеме Zero-Copy (Transferable Objects)
        // Память не копируется, а передается по указателю владения!
        self.postMessage({ type: 'BATCH_UPDATE', buffer: event.data }, [event.data]);
      }
    };
  }
};

Второй аргумент [event.data] в вызове postMessage задействует механизм Transferable Objects: браузер не клонирует байты, а мгновенно передаёт право владения памятью из воркера в главный поток за нулевое время (Zero-Copy).

Zero-Copy через Transferable Objects и мутабельный стор вне дерева React

В главном потоке мы отказываемся от создания иммутабельных копий объектов. Все 100 000 строк хранятся в нативном JavaScript-объекте Map за пределами контекста React.

Обновления применяются по месту (In-Place Mutation) с инкрементом версии строки:

// store.ts - высокопроизводительное хранилище вне дерева компонентов
export interface DataItem {
  id: number;
  price: number;
  volume: number;
  version: number;
}

type Subscriber = (version: number) => void;

export class LiveDataStore {
  private data = new Map<number, DataItem>();
  private subscribers = new Map<number, Set<Subscriber>>();
  private pendingUpdates = new Set<number>();
  private rafScheduled = false;

  public applyBinaryBuffer(buffer: ArrayBuffer) {
    const view = new DataView(buffer);
    const count = view.byteLength / 16;

    for (let i = 0; i < count; i++) {
      const offset = i * 16;
      const id = view.getInt32(offset, true);
      const price = view.getFloat64(offset + 4, true);
      const volume = view.getInt32(offset + 12, true);

      const entity = this.data.get(id);
      if (entity) {
        entity.price = price;
        entity.volume = volume;
        entity.version++;
        this.pendingUpdates.add(id);
      }
    }

    this.scheduleBatchFlush();
  }

  private scheduleBatchFlush() {
    if (this.rafScheduled) return;
    this.rafScheduled = true;

    requestAnimationFrame(() => {
      this.pendingUpdates.forEach((id) => {
        const entity = this.data.get(id);
        const subs = this.subscribers.get(id);
        if (entity && subs) {
          subs.forEach((cb) => cb(entity.version));
        }
      });
      this.pendingUpdates.clear();
      this.rafScheduled = false;
    });
  }

  public subscribe(id: number, cb: Subscriber) {
    if (!this.subscribers.has(id)) {
      this.subscribers.set(id, new Set());
    }
    this.subscribers.get(id)!.add(cb);
    return () => this.subscribers.get(id)?.delete(cb);
  }
}
Батчинг через requestAnimationFrame

Никогда не вызывайте рендер на каждый входящий сетевой пакет. Складывайте дельты в очередь и сбрасывайте изменения строго один раз за кадр через requestAnimationFrame.

Синхронизация по requestAnimationFrame и виртуализация строк

На экране через виртуализатор отображаются только те 30–50 строк таблицы, которые попадают в область видимости монитора.

Каждый компонент строки подписывается через useSyncExternalStore строго на свой id. Когда в сторе мутирует строка №42, уведомление получает только её компонент, перерисовывая две цифры на экране. Остальные строки даже не просыпаются.

Результат впечатляет: фоновый Web Worker берёт на себя приём сокетов на вторичном ядре, мутабельный стор устраняет паузы сборщика мусора, а React легко удерживает 60 FPS под напором 100 000 апдейтов в секунду.