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 дні, письмовий звіт). Контакт.