Как правильно реализовать обновления в реальном времени в крупном проекте?

Проблема: в приложении Next.js используется один общий WebSocket для трансляции спортивных событий. При потере интернета или переключении устройства логика восстановления комнат работает нестабильно: часто теряются поля в объектах, а TanStack Query не обновляет кэш, потому что ключи формируются неверно.
  • 17 мая 2026 г. 15:01
Ответы на вопрос 4
Коротко — проблема у вас не в WebSocket как таковом, а в трёх слоях, которые надо сделать над ним надёжно и согласованно:

1) надёжное восстановление/репостановка соединения (reconnect, heartbeat, backoff);  
2) корректный дизайн сообщений (snapshots + дельты + sequence numbers / versioning);  
3) корректная клиентская обработка обновлений и кэширования (нормализация данных, стабильные ключи для TanStack Query, merge/упсерт вместо «переписывания»).

Ниже — практический план + примеры, которые решат ваши симптомы (потеря полей, неверные ключи, непойманные обновления).

1) Надёжное соединение
- Один WebSocket на клиент/вкладку — это нормально. Но:
  - Реализуйте heartbeat (ping/pong). Если пропущено N подряд, перезапускаем соединение.
  - Экспоненциальный бэкофф с jitter при повторных попытках.
  - При переключении устройства/вкладки явно закрывайте старое соединение и создавайте новое (не полагайтесь на "видимось" браузера).
  - Проставляйте уникальный clientId в заголовке/параметре соединения, чтобы сервер мог связать сессии.
- Синхронизация между вкладками: BroadcastChannel / Shared Worker, чтобы иметь 1 активный socket и остальные подписывались из канала. Это убирает гонки и дублирование.

2) Схема сообщений: snapshot + deltas + sequence
- Две базовые операции:
  - full snapshot (полный снимок состояния комнаты) — на join/reconnect или при большой рассинхронизации;
  - incremental update (дельты) с минимальным набором изм. полей.
- В каждом сообщении — обязательный monotonically increasing sequenceNumber (seq) и timestamp. Пример:
  { type: 'update', roomId: 'r1', seq: 12345, payload: { matchId: 'm1', changes: { score: { home: 2 } } } }
- Логика клиента:
  - Храните последний применённый seq для каждой комнаты.
  - При получении update: если seq === lastSeq + 1 → применяем; если seq <= lastSeq → игнор (дубликат); если seq > lastSeq + 1 → мы пропустили сообщения → запрашиваем snapshot или replay с сервера.
- При reconnect отправляйте lastSeq, сервер либо будет слать все пропущенные события, либо сделает snapshot.

3) TanStack Query — стабильные ключи и корректный merge
- Ключи query должны быть детерминированные и состоять из примитивов, а не объектов:
  Плохо:
    queryKey={['room', { id: roomId }]} // новый объект каждый render → разные ключи
  Хорошо:
    queryKey={['room', roomId]}
- Используйте queryClient.setQueryData с функцией-апдейтер, чтобы не терять поля и делать merge/упсерт:
  Пример (TS/JS):
  const key = ['match', matchId] as const;
  queryClient.setQueryData(key, old => {
    if (!old) {
      // если данных ещё нет — создаём начальную структуру
      return { id: matchId, ...applyUpdateToEmpty(update) };
    }
    // делаем безопасное слияние (не перезаписываем всю сущность)
    return deepMergeImmutable(old, update); // реализация ниже
  });

- Реализация merge:
  - Для простых случаев {...old, ...update} достаточно, но это перезаписывает вложенные объекты целиком.
  - Лучше использовать глубокий merge только для полей, которые приходят в дельте (lodash.merge, immer или собственная логика).
  - Лучше — нормализовать данные (см. далее) и апдейтить по сущности: обновлять только те поля сущности, которые пришли.

4) Нормализация данных
- Для крупного проекта нормализованное хранилище (entities by id) значительно упрощает реальные-time:
  - Храните матчи, команды, события как словари { byId: { ... }, ids: [...] }.
  - Применяйте дельты как upsert: entities.match[matchId] = { ...entities.match[matchId], ...delta }.
- Это устраняет проблемы с потерей полей (потому что вы апдейтили только те поля, которые обновились).

5) Буферизация и race conditions
- Обновления могут приходить раньше, чем приложение загрузило initial query. Решение:
  - Буфер incoming updates (по roomId) до тех пор, пока queryClient.getQueryData(key) !== undefined, затем применить.
  - Или при приёме обновления использовать setQueryData, даже если данных нет — создать минимальную структуру и потом отрендерить/дополнить при fetch.

6) Пример reconnection + update handling (упрощённо)
- Reconnect + seq + apply:
  // псевдокод
  let socket;
  let lastSeqByRoom = new Map<string, number>();
  function connect() {
    socket = new WebSocket(urlWithClientId);
    socket.onopen = () => { send({ type: 'subscribe', rooms: myRooms, lastSeqs: Object.fromEntries(lastSeqByRoom) }); };
    socket.onmessage = ({ data }) => handleMessage(JSON.parse(data));
    heartbeatInit();
  }
  function handleMessage(msg) {
    if (msg.type === 'snapshot') {
      applySnapshot(msg.roomId, msg.seq, msg.payload);
    } else if (msg.type === 'update') {
      const last = lastSeqByRoom.get(msg.roomId) ?? 0;
      if (msg.seq === last + 1) {
        applyUpdate(msg.roomId, msg.seq, msg.payload);
      } else if (msg.seq > last + 1) {
        requestSnapshot(msg.roomId);
      } // else duplicate
    }
  }

  function applyUpdate(roomId, seq, update) {
    lastSeqByRoom.set(roomId, seq);
    const key = ['room', roomId];
    queryClient.setQueryData(key, old => {
      if (!old) return createFromUpdate(update); // или буферизовать
      return deepMergeImmutable(old, update);
    });
  }

7) Диагностика и логирование
- Логируйте seq, reconnect attempts, причину перезапуска socket, количество пропущенных сообщений.
- Добавьте эндпоинт/админ панель для получения snapshot по roomId и seq — удобно для отладки.

8) Альтернативы / библиотеки
- Если хочется меньше хедей для realtime: Pusher, Ably, Supabase Realtime, Socket.IO (с авто-реправкой), PostgreSQL logical replication, или GraphQL subscriptions (клиентские libs решают часть reconnect).
- Но архитектурные вещи (seq, snapshot, нормализация) останутся.

9) Короткие «antipatterns», которые нужно убрать сейчас
- Использовать объекты в queryKey; создавать новый ключ каждый render.
- Приходящие частичные обновления тупо заменяют весь объект: old -> update (потеря полей).
- Ожидать, что re-subscribe на сервере восстановит state без sequence/snapshot логики.

Резюме — минимальная рабочая конструкция:
- На сервере: поддержка seq, возможность отдать snapshot по roomId (+ опционально replay с seq).
- На клиенте:
  - стабильный clientId + heartbeat + backoff reconnect;
  - хранение lastSeq; при reconnect отправлять lastSeq и получать diff/snapshot;
  - TanStack Query keys из примитивов (['room', roomId]);
  - setQueryData с merge/upsert (или нормализованный store);
  - буферизация сообщений до появления initial data.

Если нужно — могу:
- прислать готовый пример кода для Next.js/React с использованием queryClient и immer;
- показать пример сервера (Node.js) с seq/snapshot API;
- или помочь привести в порядок конкретный участок вашего кода (покажите пример WebSocket handler + формирование queryKey + обработчик incoming message).
Провайдер нужен, но лучше не делать из него «бога». Разбей на слои: transport (только connect/reconnect/auth), subscription registry (хранит активные комнаты, при reconnect сам делает join), и event handlers где <code>setQueryData</code> если пришёл полный объект, <code>invalidateQueries</code> если partial. pathname — только сигнал «какие комнаты сейчас нужны», не основа архитектуры. И один <code>queryKeys</code> factory вместо строк в каждом хуке — это сразу уберёт половину несовпадений ключей. <br/> <br/> p.s. после reconnect обязателен resync (snapshot по HTTP или <code>sync_required</code> событие) — socket.io recovery при долгом обрыве не гарантирован.
Делал так: <br/> Да, возможно не самое оптимальное решение, но задача была сделать "вчера и масштабируемо"  (ну, как обычно) — поэтому максимально просто и тупо. Зато можно пачками плодить ноды, из узких мест — тут, конечно, в первую очередь БД, во вторую — дополнительная нагрузка и задержки на пересылку сообщений фронт-редис-бэк. В остальном вполне юзабельное решение. Чисто технически редиску можно вообще выкинуть из цепочки пересылки и оставить её только для сессий и других внутренних данных.
я бы прикрутил centrifugo все что происходит писал бы в него, а на клиенте подписывался на топики.  Центрифуга сама заботиться о сокетах и о том как тебе на клиент передать события ты их в режиме чтения только получаешь из топика, топиком может быть много. т.е. каждый тип события ты можешь пушить в свой топик.
Похожие вопросы