Skip to main content
Назад к блогу
8 мин чтения

Offline-first React Native: как мы сохранили выбор мест плавным на поезде с 3G

Архитектура, которая сделала пикер на 12K мест мгновенным при 0 полосках сигнала. Синхронизация состояния, оптимистичные записи, очередь, переживающая kill приложения, и крайние случаи, о которых никто не предупреждает.

react-nativeofflinemobile

Бриф был простой: пользователи покупают билеты на мероприятие с телефона, часто по дороге на само мероприятие. Ограничение было менее простым: типичный покупатель ехал на электричке с нестабильным 3G, переключаясь между Wi-Fi и сотовым каждые несколько остановок, иногда уходя полностью в офлайн в тоннеле.

Пикер мест с сетевым запросом на каждый тап не собирался выживать в этой среде. Вот архитектура, которую мы для этого построили, протокол синхронизации, который её скрепляет, и крайние случаи, которые всплывают только после нескольких тысяч пользователей.

Режим отказа, с которого мы начали

Наша первая версия была «нормальной»: загрузить карту мест, тапнуть место, POST /reserve, показать «зарезервировано» или «не удалось». Нормально на быстром Wi-Fi, катастрофа на поезде.

Что реально происходило:

Уровень отвала на пикере мест: 18% на сотовом, 2% на Wi-Fi. Нам нужно было опустить первое число ниже 5%.

Переписывание — offline-first с нуля

Ментальный сдвиг: телефон — источник истины для того, что сделал пользователь. Сервер — источник истины для того, что доступно. Это две разные вещи, и мы перестали их путать.

Архитектура, упрощённо:

  1. При запуске приложения (или открытии события): фетчим полную доступность мест, кешируем в SQLite.
  2. При тапе на место: обновляем локальное состояние немедленно, ставим операцию синхронизации в очередь, показываем спиннер только на явном действии (чекаут).
  3. При наличии сети: дренируем очередь по порядку, реконсилим с ответом сервера.
  4. При конфликте (сервер говорит, что место уже занято): показываем пользователю конфликт с UI повтора/альтернативы, не молча дропаем.
  5. При kill приложения с незавершёнными операциями: очередь персистится на диск, восстанавливается при следующем запуске.

Ощущение для пользователя: каждый тап мгновенный. Каждый экран навигируем. Приложение «спрашивает сеть» только на чекауте, и даже тогда пользователь видит прогресс на знакомом экране «подготовка заказа» вместо 4-секундного пустого экрана.

Очередь — самая сложная часть

Очередь синхронизации звучит просто. Это не так, потому что ты реализуешь маленькую распределённую систему, узлы которой — телефон и сервер, которые могут не совпадать в понимании реальности в любой момент.

Требования, к которым мы пришли:

  1. Персистентная — переживает kill приложения, перезагрузки телефона, обновления ОС.
  2. Упорядоченная — операции воспроизводятся в порядке, в котором их сделал пользователь.
  3. Идемпотентная — ретрай не дублирует действие.
  4. Осведомлённая о конфликтах — если сервер говорит «нет», пользователь об этом слышит.
  5. Наблюдаемая — мы видим, что в очереди, что ретраится, что упало.

Стек: 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». Нам нужно было больше.

Каждый ответ синхронизации несёт:

Клиент:

  1. На ok — помечает op как committed, убирает из очереди.
  2. На conflict (место занято кем-то до прихода нашей op) — помечает op как failed, показывает пользователю UI «попробуй другое место».
  3. На stale (представление пользователя о доступности устарело) — молча обновляет карту мест и ретраит op против нового состояния.
  4. На 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.

Поэтому: реагируй мгновенно, реконсиль тихо, показывай конфликты с ясным путём восстановления. Модель пользователя о том, что произошло, реконсилится с моделью сервера только на чекауте, где слегка более долгое состояние загрузки — ожидаемое поведение.

Цифры после переписывания

Последнее число было самым приятным. Пользователи, которые были confused до этого, не были технически неграмотными — они были в поездах. Архитектура изменилась, и продукт начал встречать их там, где они есть.

Когда тянуться к этому

Не нужен offline-first для каждого приложения. Порог, который я использую:

Для E7 Shop конкретно юзкейс — покупатели билетов по дороге на мероприятие. Это почти по определению движущаяся среда с нестабильной сетью. Offline-first не был nice-to-have; он был продуктом.

Урок, который я продолжаю переучивать

Каждый раз, когда строю что-то mobile-first, я недооцениваю, как много от ощущения «быстро» — это реально «реагирует на мой ввод, независимо от того, работает ли сеть». Спиннер-на-каждый-тап — не производительность. Это признание, что ты строишь для человека за столом на оптике — а потом отгружаешь людям в поездах.


Если строишь React Native продукт, который должен выживать на нестабильных сетях, и хочешь вторую пару глаз на архитектуру синхронизации — я делаю разовые ревью мобильных кодовых баз (€500, оборот 3 дня, письменный отчёт). Контакт.