Skip to main content

Data Synchronization

Архитектура синхронизации данных между backend и TMA frontend.

Core Principle

Гибридная стратегия: Bootstrap для начальной загрузки + Optimistic Updates для действий пользователя + SSE для real-time событий + Smart Refresh при возврате в приложение.


1. Summary

Проблема: TMA должен ощущаться как native app, а не как медленный веб-сайт с постоянными loading спиннерами.

Решение:

  • Один запрос /api/bootstrap вместо 8+ отдельных при старте
  • Optimistic updates — UI реагирует мгновенно, до ответа сервера
  • SSE для real-time событий (feed, raffle)
  • Smart refresh при возврате в приложение (window focus)

Почему НЕ WebSocket / НЕ Polling:

  • 10k DAU × polling 30 сек = 200k req/min — убийственная нагрузка
  • WebSocket держит соединение — сложность + ресурсы сервера
  • TMA "засыпает" когда Telegram свёрнут — соединение всё равно рвётся

2. Архитектура


3. Bootstrap

Что загружается

Один запрос GET /api/bootstrap возвращает ~60-80 KB JSON:

ДанныеОписаниеstaleTime
profileБаланс, уровень, XP2 min
questsСписок с прогрессом5 min
achievementsСписок с прогрессом10 min
casesДоступные кейсы10 min
inventoryИнвентарь (до 100 предметов)3 min
streakСтатистика стриков5 min
seasonТекущий сезон30 min
activeBuffsАктивные баффы5 min
leaderboardТоп-10 + позиция юзера30 min
dailySpinsАктивные рулетки5 min
craftableItemsСкины для крафта10 min
initialFeedПоследние 10 событий30 min
referralРеферальная статистика30 min
raffleАктивный розыгрыш30 min
bannersРекламные баннеры30 min

Гидратация кэша

После получения данных — hydrateBootstrapData() заполняет React Query кэш:

// Каждый домен получает свой queryKey
queryClient.setQueryData(['profile'], data.profile);
queryClient.setQueryData(['quests', undefined], data.quests);
queryClient.setQueryData(['inventory', undefined], data.inventory);
// ... и так для всех 20+ доменов

Результат: При навигации на любой экран — данные уже в кэше, нет loading спиннеров.

Предзагрузка изображений

Bootstrap также триггерит предзагрузку динамических изображений:

Phase 1: Critical (scrap.webp, case.webp, xp.webp) — ~120ms
Phase 2: Static (blueprints, items) — ~2-3s
Phase 3: Dynamic (скины из inventory, кейсов, наград) — timeout 7s

Ключевые файлы

ФайлНазначение
frontend/src/services/bootstrap.api.tsAPI клиент
frontend/src/hooks/useBootstrap.tsHook загрузки
frontend/src/utils/bootstrapHydration.tsГидратация кэша
frontend/src/utils/imagePreloader.tsПредзагрузка изображений
frontend/src/components/LoadingOrchestrator.tsxУправление loading states

4. Optimistic Updates

Паттерн

const mutation = useMutation({
mutationFn: openCase,

// 1. ДО запроса — мгновенное обновление UI
onMutate: async ({ casePrice }) => {
await queryClient.cancelQueries({ queryKey: ['profile'] });
const previous = queryClient.getQueryData(['profile']);

// Optimistic: списываем баланс сразу
queryClient.setQueryData(['profile'], (old) => ({
...old,
scrap: old.scrap - casePrice,
}));

return { previous };
},

// 2. При ОШИБКЕ — откат к предыдущему состоянию
onError: (err, variables, context) => {
queryClient.setQueryData(['profile'], context.previous);
},

// 3. При УСПЕХЕ — синхронизация с сервером
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['inventory'] });
},
});

Где используется

ДействиеOptimistic UpdateФайл
Открытие кейсаScrap списывается мгновенноuseOpenCase.ts
Salvage предметаПредмет удаляется из инвентаряuseInventory.ts
Claim наградыXP/Scrap добавляетсяuseRewardClaim.ts

5. Real-Time (SSE)

Activity Feed

// useLiveFeed.ts
const eventSource = new EventSource('/api/feed/live');

eventSource.onmessage = (e) => {
const { type, data } = JSON.parse(e.data);

if (type === 'init') {
setEvents(data); // Начальные события
} else if (type === 'new') {
setEvents(prev => [data, ...prev].slice(0, 10)); // Новое событие
}
};

// Автопереподключение при ошибке через 5 сек
eventSource.onerror = () => {
setTimeout(() => reconnect(), 5000);
};

Raffle Live Feed

// useRaffleLiveFeed.ts
const eventSource = new EventSource(`/api/raffle/${raffleId}/live`);

// События: ticket_purchased, raffle_completed
eventSource.onmessage = (e) => {
const event = JSON.parse(e.data);
// Дедупликация по timestamp + oderId
if (!isDuplicate(event)) {
setEvents(prev => [event, ...prev].slice(0, 5));
}
};

Почему SSE, а не WebSocket

КритерийSSEWebSocket
НаправлениеServer → Client (достаточно для нас)Bidirectional
СложностьHTTP-based, простой fallbackОтдельный протокол
ReconnectАвтоматическийНужно реализовывать
НагрузкаЛегче для сервераДержит соединение

6. Background Refresh

Window Focus

При возврате в TMA (когда пользователь открывает Telegram):

// useAppVisibility.ts
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// High priority — всегда обновляем
queryClient.invalidateQueries({ queryKey: ['profile'] });

// Medium priority — если AFK > 5 минут
if (timeSinceLastVisit > 5 * 60 * 1000) {
queryClient.invalidateQueries({ queryKey: ['inventory'] });
}
}
});

Polling (только для некритичных данных)

ДанныеИнтервалЗачем
Banners5 minРотация рекламы
Active Buffs1 minТаймер оставшегося времени
Season1 minCountdown до конца сезона
Polling для критичных данных — NO!

Profile, Inventory, Quests — никогда не используют polling. Это убьёт сервер при 10k DAU.


7. External Events (Rust Webhooks)

Проблема

Rust плагин отправляет webhook когда игрок выполнил квест на сервере. Frontend не узнаёт об этом мгновенно — нет push-канала.

Решение (текущее)

Почему это OK

  1. Естественный флоу: Пользователь играет → возвращается проверить прогресс
  2. Нагрузка: Нет лишних запросов пока пользователь играет
  3. TMA специфика: Telegram Mini App "засыпает" когда свёрнут

Альтернативы (если потребуется)

ПодходСложностьКогда использовать
SSE для квестовСредняяЕсли UX станет проблемой
Telegram Bot notificationНизкая"Квест выполнен! Заберите награду"
Short polling для IN_PROGRESSНизкая30 сек только для активных квестов

8. React Query Configuration

Global Defaults

// QueryProvider.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 min — данные считаются свежими
gcTime: 30 * 60 * 1000, // 30 min — кэш держится в памяти
refetchOnWindowFocus: false, // Отключено глобально
refetchOnReconnect: true, // При восстановлении сети
retry: 2, // 2 попытки при ошибке
},
},
});

Per-Hook Overrides

Критичные данные переопределяют глобальные настройки:

// useUserProfile.ts
useQuery({
queryKey: ['profile'],
staleTime: 2 * 60 * 1000, // 2 min (чаще обновляется)
refetchOnWindowFocus: true, // Включено для профиля
});

9. Zustand vs React Query

Разделение ответственности

Zustand StoreReact Query Cache
UI состояния (модалки, анимации)Server state (profile, quests)
Локальные обновления (addScrap)Кэширование ответов API
Глобальные награды (TopFloatingReward)staleTime / refetch логика

Синхронизация

// useUserProfile.ts
useEffect(() => {
if (query.data) {
// Синхронизируем React Query → Zustand
useUserStore.setState({ profile: query.data });
}
}, [query.data]);

10. Client-Side Storage

localStorage Strategy

Помимо in-memory React Query cache, приложение использует localStorage для:

Тип данныхКлючНазначение
UI Preferencesgoloot-settings-storagesound, vibration, theme (Zustand persist)
Dismissed Hintsgoloot-hintsКонтекстные подсказки (Zustand persist)
Balance TrackinglastKnownBalanceОтслеживание "внешних наград"
AFK Detectiongoloot-last-visit-timeSmart refetch при возврате
Banner UXbanner_hidden_{id}Скрытые баннеры (30 min TTL)

Total footprint: ~500-1000 bytes (очень лёгкий).

Разделение ответственности

React Query (in-memory)localStorage
Server state (profile, quests)UI preferences (theme, sound)
Кэширование API ответовПерсистентные настройки
staleTime / refetch логикаOffline-first UX
~80 KB bootstrap payload~1 KB preferences

Подробнее

См. Client-Side Storage для детальной документации стратегии localStorage.


11. Trade-offs Summary

РешениеПреимуществоКомпромисс
Bootstrap1 запрос вместо 8+Больший payload (~80KB)
Optimistic UpdatesМгновенный UI откликСложнее реализация
SSE (не WebSocket)Проще, HTTP-basedТолько server→client
Window Focus (не Polling)Нет лишней нагрузкиДанные не real-time
Нет push для RustПростота, экономия ресурсовЗадержка обнаружения