Data Synchronization
Архитектура синхронизации данных между backend и TMA frontend.
Гибридная стратегия: 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 | Баланс, уровень, XP | 2 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.ts | API клиент |
frontend/src/hooks/useBootstrap.ts | Hook загрузки |
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
| Критерий | SSE | WebSocket |
|---|---|---|
| Направление | 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 (только для некритичных данных)
| Данные | Интервал | Зачем |
|---|---|---|
| Banners | 5 min | Ротация рекламы |
| Active Buffs | 1 min | Таймер оставшегося времени |
| Season | 1 min | Countdown до конца сезона |
Profile, Inventory, Quests — никогда не используют polling. Это убьёт сервер при 10k DAU.
7. External Events (Rust Webhooks)
Проблема
Rust плагин отправляет webhook когда игрок выполнил квест на сервере. Frontend не узнаёт об этом мгновенно — нет push-канала.
Решение (текущее)
Почему это OK
- Естественный флоу: Пользователь играет → возвращается проверить прогресс
- Нагрузка: Нет лишних запросов пока пользователь играет
- 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 Store | React 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 Preferences | goloot-settings-storage | sound, vibration, theme (Zustand persist) |
| Dismissed Hints | goloot-hints | Контекстные подсказки (Zustand persist) |
| Balance Tracking | lastKnownBalance | Отслеживание "внешних наград" |
| AFK Detection | goloot-last-visit-time | Smart refetch при возврате |
| Banner UX | banner_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
| Решение | Преимущество | Компромисс |
|---|---|---|
| Bootstrap | 1 запрос вместо 8+ | Больший payload (~80KB) |
| Optimistic Updates | Мгновенный UI отклик | Сложнее реализация |
| SSE (не WebSocket) | Проще, HTTP-based | Только server→client |
| Window Focus (не Polling) | Нет лишней нагрузки | Данные не real-time |
| Нет push для Rust | Простота, экономия ресурсов | Задержка обнаружения |
Related
- Client-Side Storage — стратегия localStorage и in-memory cache
- Overview — общая архитектура системы
- Redis Integration — кэширование на backend
- Rust Integration — webhook API