Бриф був простий: користувачі купують квитки на подію з телефону, часто по дорозі на саму подію. Обмеження було менш простим: типовий покупець їхав на електричці з нестабільним 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 дні, письмовий звіт). Контакт.