Бриф был простой: пользователи покупают билеты на мероприятие с телефона, часто по дороге на само мероприятие. Ограничение было менее простым: типичный покупатель ехал на электричке с нестабильным 3G, переключаясь между Wi-Fi и сотовым каждые несколько остановок, иногда уходя полностью в офлайн в тоннеле.
Пикер мест с сетевым запросом на каждый тап не собирался выживать в этой среде. Вот архитектура, которую мы для этого построили, протокол синхронизации, который её скрепляет, и крайние случаи, которые всплывают только после нескольких тысяч пользователей.
Режим отказа, с которого мы начали
Наша первая версия была «нормальной»: загрузить карту мест, тапнуть место, POST /reserve, показать «зарезервировано» или «не удалось». Нормально на быстром Wi-Fi, катастрофа на поезде.
Что реально происходило:
- Пользователь тапает место, видит ничего 4 секунды, пока запрос висит.
- Тапает снова, думая, что первый тап не зарегистрировался. Теперь он отправил два запроса на два разных места.
- Первый запрос наконец возвращается. Второй таймаутит на 30 секундах.
- Пользователь скроллит, не уверен, что зарезервировано, а что нет.
- Сдаётся и закрывает приложение.
Уровень отвала на пикере мест: 18% на сотовом, 2% на Wi-Fi. Нам нужно было опустить первое число ниже 5%.
Переписывание — offline-first с нуля
Ментальный сдвиг: телефон — источник истины для того, что сделал пользователь. Сервер — источник истины для того, что доступно. Это две разные вещи, и мы перестали их путать.
Архитектура, упрощённо:
- При запуске приложения (или открытии события): фетчим полную доступность мест, кешируем в SQLite.
- При тапе на место: обновляем локальное состояние немедленно, ставим операцию синхронизации в очередь, показываем спиннер только на явном действии (чекаут).
- При наличии сети: дренируем очередь по порядку, реконсилим с ответом сервера.
- При конфликте (сервер говорит, что место уже занято): показываем пользователю конфликт с UI повтора/альтернативы, не молча дропаем.
- При kill приложения с незавершёнными операциями: очередь персистится на диск, восстанавливается при следующем запуске.
Ощущение для пользователя: каждый тап мгновенный. Каждый экран навигируем. Приложение «спрашивает сеть» только на чекауте, и даже тогда пользователь видит прогресс на знакомом экране «подготовка заказа» вместо 4-секундного пустого экрана.
Очередь — самая сложная часть
Очередь синхронизации звучит просто. Это не так, потому что ты реализуешь маленькую распределённую систему, узлы которой — телефон и сервер, которые могут не совпадать в понимании реальности в любой момент.
Требования, к которым мы пришли:
- Персистентная — переживает kill приложения, перезагрузки телефона, обновления ОС.
- Упорядоченная — операции воспроизводятся в порядке, в котором их сделал пользователь.
- Идемпотентная — ретрай не дублирует действие.
- Осведомлённая о конфликтах — если сервер говорит «нет», пользователь об этом слышит.
- Наблюдаемая — мы видим, что в очереди, что ретраится, что упало.
Стек: react-native-mmkv для быстрого чтения/записи, JSON-блоб на операцию, фоновый сервис, дренирующий очередь при наличии связи по NetInfo.
// упрощённо
type QueueOp =
| { id: string; type: 'reserve'; seatId: string; clientTs: number }
| { id: string; type: 'release'; seatId: string; clientTs: number }
| { id: string; type: 'checkout'; cartId: string; clientTs: number }
// Каждая op получает клиент-генерируемый UUID. Сервер использует его для идемпотентности.
API сервера принимает клиент-генерируемый UUID как ключ идемпотентности. Дублирующие запросы (от ретраев) возвращают оригинальный ответ без повторной обработки. Тот же паттерн, что я использую для вебхуков Stripe — та же проблема, тот же фикс.
Протокол синхронизации — что сервер отправляет назад
Наивный протокол — «200 OK» или «409 Conflict». Нам нужно было больше.
Каждый ответ синхронизации несёт:
status—ok|conflict|stale|errorlatestState— текущий вид сервера на места, с которыми пользователь взаимодействовалtimestamp— серверное время ответа
Клиент:
- На
ok— помечает op как committed, убирает из очереди. - На
conflict(место занято кем-то до прихода нашей op) — помечает op как failed, показывает пользователю UI «попробуй другое место». - На
stale(представление пользователя о доступности устарело) — молча обновляет карту мест и ретраит op против нового состояния. - На
error(5xx или сеть) — ретраит с экспоненциальным backoff, до 3 попыток, затем фолбечится на «conflict» UX.
Четырёхсостояльная модель появилась из наблюдения за реальными пользователями. «Conflict» и «stale» ощущаются одинаково для кода, но совершенно по-разному для пользователя. Conflict значит «попробуй другое место». Stale значит «подожди, мы обновили карту, попробуй ещё раз». Объединение их сделало бы UX ненадёжным.
Крайние случаи, о которых никто не предупреждает
Все пришли из продакшна.
1. Мигание Wi-Fi
iOS особенно любит говорить «онлайн», когда ты подключился к Wi-Fi сети, до того, как прошёл captive portal в кофейне. NetInfo говорит онлайн. API-вызовы таймаутят. Очередь начинает сливаться в пустоту.
Фикс: не доверяй isConnected. Добавь проверку isInternetReachable и пробу к своему health-эндпоинту перед началом дренирования. Запрос, упавший с сетевой ошибкой, не должен уменьшать счётчик ретраев — считай это как «всё ещё офлайн».
2. Фоновые kill'ы Android
Android агрессивно убивает приложение для высвобождения памяти. Если ты в середине синка, когда это происходит — можешь оставить ops в состоянии «flushing», которое никогда не резолвится.
Фикс: очередь хранит только «pending» или «committed». Промежуточного состояния «flushing» нет. Дренирование идемпотентно — если op уже отправлена, сервер возвращает закешированный ответ по ключу идемпотентности. Kill приложения посреди flush, рестарт, реплей, нет дубликатов.
3. Сдвиг часов между устройством и сервером
Пользователь на дёрганом поезде, часы телефона на 3 минуты отстают. Резервирует место, потом отпускает, потом резервирует снова. Сервер получает в порядке, основанном на времени приёма сервером. Клиент думает, что отправил в другом порядке. UI показывает одно; сервер возвращает другое.
Фикс: каждая op несёт монотонный клиентский sequence number (не таймстамп). Сервер использует его только для тайбрейка, не для упорядочивания. Истина — порядок получения на сервере.
4. Проблема «я просто открою приложение позже»
Пользователь резервирует место в 10:00. Приложение уходит в фон. Телефон садится. Заряжает днём. Открывает приложение в 16:00. Резервация истекла в 10:15. Локальное состояние всё ещё думает, что место его.
Фикс: любое закешированное состояние старше TTL резервации мягко инвалидируется. При повторном открытии первое действие — GET /event/:id/state, который реконсилит. Если места, которые пользователь считал своими, ушли — он видит вежливый экран «ваш выбор истёк, вот что доступно» вместо «обработка платежа».
5. Одновременное открытие на двух устройствах
Некоторые пользователи залогинены на iPad и телефоне. Выбирают места на одном, переключаются на другой, ожидают продолжения.
Фикс: корзина серверная, по ключу пользователь + событие. Оба устройства читают одно состояние. Это было больше фичей, чем крайним случаем — но стоит упомянуть, потому что offline-first модель сделала это проще, а не сложнее. Сервер всегда авторитетен для корзины; оба устройства сходятся.
Сложная часть, которая не была технической
Реализация этой архитектуры — две недели. Убеждение остальной команды, что «тап ощущается мгновенным» важнее, чем «тап верифицирован» — два совещания. Аргумент, к которому мы возвращались:
На нестабильной сети пользователь не может отличить «сеть работает и место зарезервировано» от «сеть работает и кто-то другой забрал место первым». Оба ощущаются как «я тапнул, и ничего не случилось». Единственное, что пользователь может отличить — реагирует ли UI.
Поэтому: реагируй мгновенно, реконсиль тихо, показывай конфликты с ясным путём восстановления. Модель пользователя о том, что произошло, реконсилится с моделью сервера только на чекауте, где слегка более долгое состояние загрузки — ожидаемое поведение.
Цифры после переписывания
- Отвал на сотовом упал с 18% до 3.2%.
- Завершение чекаута на 3G упало с 61% до 94%.
- Среднее время в пикере упало с 82 секунд до 47.
- Тикеты в поддержку о «место не выбралось» — с ~12/неделю до нуля.
Последнее число было самым приятным. Пользователи, которые были confused до этого, не были технически неграмотными — они были в поездах. Архитектура изменилась, и продукт начал встречать их там, где они есть.
Когда тянуться к этому
Не нужен offline-first для каждого приложения. Порог, который я использую:
- Пропускай для чисто connected-приложения (Zoom, режим вождения Uber, любое приложение, где «нет сети» значит «нет продукта»). Сложность реальная, и стоит избегать, если юзкейс не требует.
- Тянись когда пользователь использует приложение во время переходов — транспорт, путешествия, мероприятия, удалённые места — и многосекундное состояние загрузки реально ломает флоу.
Для E7 Shop конкретно юзкейс — покупатели билетов по дороге на мероприятие. Это почти по определению движущаяся среда с нестабильной сетью. Offline-first не был nice-to-have; он был продуктом.
Урок, который я продолжаю переучивать
Каждый раз, когда строю что-то mobile-first, я недооцениваю, как много от ощущения «быстро» — это реально «реагирует на мой ввод, независимо от того, работает ли сеть». Спиннер-на-каждый-тап — не производительность. Это признание, что ты строишь для человека за столом на оптике — а потом отгружаешь людям в поездах.
Если строишь React Native продукт, который должен выживать на нестабильных сетях, и хочешь вторую пару глаз на архитектуру синхронизации — я делаю разовые ревью мобильных кодовых баз (€500, оборот 3 дня, письменный отчёт). Контакт.