Приложение работает на Wi-Fi, но умирает в поле: как строят софт для плохого интернета
Разработчик из Африки разобрал архитектуру приложений, которые не ломаются при нестабильном интернете. Технологии, которые стоят за этим подходом, актуальны для любого региона с ненадёжной связью — а это почти весь мир.
Табличка «У вас нет интернета» не помогает пользователю завершить задачу. Настоящее решение — архитектура, которая изначально не ждёт, что сеть будет работать.
Представьте: полевой агент в Нигерии заполняет инспекционную форму на планшете. Он двадцать минут вносит данные, нажимает «Отправить» — и всё пропадает. Запрос не прошёл, тайм-аут, приложение сбросило форму. С точки зрения разработчика это обычный failed API request. С точки зрения пользователя — программа, которой нельзя доверять.
Имам Абубакар, разработчик, строящий продукты для африканского рынка, опубликовал детальный разбор архитектуры offline-first приложений. Статья техническая, но проблема, которую она описывает, касается всех: нестабильный интернет — это не экзотика развивающихся стран. Это маршрутка в центре Москвы, подвал в новостройке, дача в Подмосковье, поезд РЖД.
Почему проверка «есть ли интернет» не работает
Первая ошибка, которую допускают инженеры, — treating connectivity as boolean. Есть связь или нет. В реальности всё сложнее. Устройство может формально быть «онлайн», но при этом:
- соединение с потерей пакетов,
- DNS не резолвится,
- мобильная сеть переключается между 3G и 4G,
- загрузка работает, а отправка — нет,
- запрос стартует, но обрывается на полпути.
Простая проверка navigator.onLine бесполезна. Архитектура должна исходить из того, что любой сетевой запрос может провалиться, задублироваться или прийти после того, как пользователь уже закрыл экран.
Главный принцип: сначала локально, потом в сеть
Классическое приложение работает так: пользователь нажал кнопку → запрос на сервер → ожидание → ответ → обновление интерфейса. Если сеть упала, всё зависает.
Offline-first переворачивает этот поток:
- Пользователь совершил действие.
- Приложение валидирует и сохраняет изменение локально.
- Интерфейс обновляется сразу.
- Операция попадает в очередь на синхронизацию.
- Когда сеть появляется, очередь отправляется на сервер.
- Сервер подтверждает или отклоняет — локальная запись обновляется.
Сеть по-прежнему важна, но она больше не блокирует каждое действие пользователя. Именно это отличает приложение, которое просто показывает баннер «Нет интернета», от приложения, которое продолжает работать.
Шесть слоёв нормальной архитектуры
Практическая реализация offline-first разбивается на шесть компонентов:
- Интерфейс — читает данные из локальной базы, а не из ответов API.
- Локальная база данных — не временный кэш, а полноценная часть архитектуры.
- Слой доступа к данным (repository) — абстракция между UI и хранилищем.
- Outbox-очередь — персистентное хранилище операций, ожидающих отправки.
- Движок синхронизации — решает, когда и что отправлять, как обрабатывать ошибки.
- Удалённый API — серверная часть с поддержкой идемпотентности.
Для мобильных приложений локальной базой обычно служит SQLite (через Room на Android, Core Data на iOS). Для веба — IndexedDB. Важно: localStorage не подходит — он синхронный, ограничен по объёму и не поддерживает транзакции.
Outbox: очередь, которая не теряется
Ключевой элемент — durable outbox, таблица с операциями, которые нужно отправить на сервер. Если приложение упало, система убила процесс или пользователь перезагрузил телефон, in-memory очередь исчезнет. Outbox в базе данных — нет.
Критический нюанс: локальная запись и операция outbox должны создаваться в одной транзакции. Иначе получится ситуация, когда заказ сохранён локально, но операция его отправки потеряна. Пользователь думает, что всё в порядке, — а на сервер ничего не ушло.
Идемпотентность: страховка от дублей
Слабая сеть генерирует дублирующие запросы. Клиент отправил запрос, получил его, но ответ потерялся. Клиент отправляет повторно. Без защиты сервер создаст заказ дважды.
Решение — идемпотентный ключ. Каждая мутация содержит уникальный заголовок. Сервер запоминает ключ и результат. При повторном запросе с тем же ключом возвращает предыдущий ответ вместо повторного выполнения.
Это критично для всего, что имеет финансовые последствия: платежи, заказы, списания, бронирования, инвойсы.
Движок синхронизации: больше, чем таймер
Синхронизация — не функция, которая запускается при обнаружении соединения. Нормальный движок решает:
- Какие операции обрабатывать первыми.
- Сколько запросов запускать параллельно.
- Как повторять неудачные попытки.
- Что делать после истечения токена авторизации.
- Как обрабатывать конфликты.
- Стоит ли ждать Wi-Fi для больших загрузок.
- Как восстановиться после краша.
Поводов для синхронизации много: запуск приложения, возврат из фона, обновление токена, изменение сети, ручная кнопка «Синхронизировать», фоновая задача операционной системы. Полагаться на один триггер нельзя — мобильные ОС могут ограничивать фоновую работу.
Повторные попытки: экспоненциальная задержка
Повторять failed request в tight loop — пустая трата батареи, трафика и серверных ресурсов. Правильный подход — экспоненциальный backoff с jitter:
- 1-я попытка через 1 секунду,
- 2-я — через 2,
- 3-я — через 4,
- 4-я — через 8,
- и так далее до максимума.
Jitter (случайная добавка) нужен, чтобы тысячи клиентов не бомбили сервер одновременно в момент восстановления сети.
Не каждый сбой стоит повторять. Таймауты и сетевые ошибки — да. Ошибки валидации, проблемы с правами доступа, удалённые аккаунты — нет. Их нужно показывать пользователю или переводить в состояние permanent failure.
Конфликты: это не баг, это продуктовое решение
Конфликт возникает, когда одни и те же данные меняются в нескольких местах до синхронизации. Полевой агент обновил адрес клиента оффлайн, админ — из дашборда. Кто победит?
Стратегий несколько:
- Server wins — сервер отвергает устаревшие локальные правки.
- Client wins — последняя локальная правка перезаписывает сервер. Просто, но опасно.
- Last write wins — побеждает самая свежая по метке времени. Ненадёжно, если часы устройств расходятся.
- Версионирование — каждая запись имеет номер версии. Клиент отправляет ту, которую редактировал. Если сервер уже на другой версии — конфликт, 409.
- Merge по полям — изменения в разных полях объединяются: один обновил телефон, другой — адрес.
Нет универсальной стратегии. Разрешение конфликтов — часть продуктовой спецификации, а не только базы данных.
API: меньше данных, меньше страданий
Offline-first клиент всё равно будет работать плохо, если API тянет тонну данных. Оптимизации:
- Пагинация с курсором вместо загрузки всего датасета.
- Дельта-синхронизация — загружать только изменившееся.
- Сжатие ответов.
- Выборка только нужных полей.
- Миниатюры вместо полных изображений.
- Батчинг запросов — не 15 мелких запросов на один экран.
Медиа — отдельная боль
Изображения, видео, голосовые — основная нагрузка на сеть. Их нельзя обрабатывать как обычные JSON-запросы. Правильный flow:
- Сохранить медиа локально.
- Создать превью.
- Сжать при необходимости.
- Добавить загрузку в очередь.
- Загружать по частям, с возможностью возобновления.
- Хранить прогресс загрузки.
- Заменить локальную ссылку на удалённый URL после завершения.
Пользователь должен иметь выбор: загружать большие файлы через мобильный данные или только по Wi-Fi.
Безопасность: оффлайн не значит «без замка»
Офлайн-доступ ставит вопросы безопасности. Приложению может понадобиться работать после истечения access-токена, но до контакта с сервером авторизации. Принципы:
- Доступ только к ранее авторизованным локальным данным.
- Шифрование локальной базы или чувствительных полей.
- Запрет на финализацию рискованных действий оффлайн.
- Очередь низкорискованных операций до проверки сессии.
- Очистка данных при выходе.
- Изоляция хранилища для каждого аккаунта.
Украденное устройство не должно превращаться в полную копию аккаунта пользователя.
Пользователь должен понимать, что происходит
Не скрывайте синхронизацию полностью. Пользователю нужны понятные состояния:
- Сохранено на устройстве.
- Ожидает синхронизации.
- Синхронизируется.
- Синхронизировано.
- Ошибка синхронизации.
- Требуется внимание.
- Обнаружен конфликт.
«Что-то пошло не так» — бесполезное сообщение. «Сохранено на устройстве. Загрузим, когда появится связь» — значительно лучше.
Когда offline-first не нужен
Не каждый продукт требует полной офлайн-поддержки мутаций. Это добавляет сложности в модели данных, тестирование, обработку конфликтов, безопасность, дизайн. Маркетинговый сайт может обойтись кэшированными ассетами. Банковское приложение может позволить готовить транзакцию оффлайн, но требовать соединение перед финальной отправкой.
Ключевой вопрос: что пользователь должен суметь сделать, когда сеть упадёт? Ответ на него определяет объём офлайн-поддержки.
Табличка «Нет интернета» говорит пользователю, что у него проблема. Архитектура offline-first позволяет ему продолжить работу. Это разница между приложением, которое зависит от сети, и приложением, которое использует сеть как ресурс — а не как обязательное условие.