Коротко — проблема у вас не в 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).