Большинство разработчиков думают об офлайн-режиме в последнюю очередь - когда приложение уже готово, дизайн согласован, а PM давит на дедлайн. В результате пользователь видит белый экран, зависший спиннер или, что хуже - молча потерянные данные. Эта статья про то, как выстроить честный UX для состояний без сети: от психологии тревоги до кода с экспоненциальным откатом, от визуального языка ошибок до стратегий разрешения конфликтов. Всё это пригодится при разработке любого мобильного или веб-приложения, которое работает в условиях нестабильного соединения - а таких большинство.
Психология офлайн-состояний
Есть один паттерн, который я наблюдал в юзабилити-тестах снова и снова: пользователь нажимает кнопку «Отправить», ничего не происходит, он нажимает ещё раз - и ещё раз. Через десять секунд он либо закрывает приложение, либо начинает злиться. Это не проблема сети. Это проблема неопределённости.
Неопределённость порождает тревогу. Тревога - поведение избегания. Пользователь не знает, отправилось ли его сообщение, сохранился ли документ, прошла ли транзакция. Мозг интерпретирует молчание системы как угрозу: «Что-то пошло не так, и я не знаю что». Именно поэтому хорошо спроектированный офлайн-UX - это не техническая задача, а задача управления тревогой.
Три состояния, которые нужно различать: система работает нормально, система работает медленно или нестабильно, система полностью офлайн. Каждое из них требует своего визуального ответа. Смешать их - значит запутать пользователя ещё больше. Если при медленном соединении показывать те же индикаторы, что и при полном отсутствии сети, пользователь не понимает, стоит ли ему ждать или уже поздно.
Ещё один важный момент: люди значительно лучше переносят ожидание, когда знают его причину и хоть примерную длительность. Это давно известно из исследований очередей и UX загрузки. Офлайн - не исключение. «Нет интернета» - уже лучше, чем пустой экран. «Нет интернета, данные сохранены локально и будут отправлены при восстановлении соединения» - это уже доверие.
Визуальный язык офлайн-состояний
Первое правило - не прятать. Я видел интерфейсы, где при отсутствии сети просто отключали кнопки и не давали никаких объяснений. Пользователь смотрит на серую кнопку и не понимает: это фича? Это баг? Почему именно сейчас?
Офлайн-состояние должно быть явным. Баннер вверху экрана с нейтральным (не красным - красный сигнализирует об ошибке, а не о состоянии среды) цветом, иконка в статус-баре, тонкое изменение UI - всё это работает. Главное правило: элементы интерфейса, которые недоступны из-за отсутствия сети, должны быть видимы, но явно задизейблены с объяснением причины. Не скрыты, а именно задизейблены с тултипом или иным сообщением.
Хорошая практика для мобильных приложений - показывать состояние соединения в toolbar или navigationBar. На iOS можно наблюдать за NWPathMonitor и обновлять UI реактивно:
import Network final class NetworkMonitor: ObservableObject { private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") @Published var isConnected: Bool = true init() { monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isConnected = path.status == .satisfied } } monitor.start(queue: queue) } }
В SwiftUI это тривиально подключается через @EnvironmentObject и позволяет любому экрану реагировать на изменение состояния сети без лишней связанности.
Для иконографики: я предпочитаю SF Symbol wifi.slash на iOS - он мгновенно считывается пользователем. На вебе - аналогичный подход с SVG-иконками, но важно не злоупотреблять анимацией. Пульсирующая иконка офлайна раздражает через 30 секунд. Статичная - просто информирует.
Очередь действий для последующей синхронизации
Вот где начинается настоящая инженерия. Когда пользователь совершает действие офлайн - ставит лайк, создаёт задачу, редактирует документ - это действие нужно куда-то сохранить и выполнить позже. Простая реализация через массив в памяти не переживёт перезапуск приложения. Нужен персистентный store: Core Data, SQLite, или хотя бы UserDefaults для простых случаев.
Но персистентность - это только половина задачи. Вторая половина - идемпотентность операций.
Идемпотентность означает, что повторное выполнение одной и той же операции не изменит результат. Это критически важно для очереди синхронизации, потому что мы не знаем, дошёл ли запрос до сервера до потери соединения. Если операция не идемпотентна, повторная отправка может создать дубликат или сломать данные.
Практически это решается двумя способами. Первый - использовать клиентский UUID для каждой операции и передавать его в заголовке Idempotency-Key. Сервер хранит этот ключ и при повторном запросе возвращает тот же ответ, не выполняя операцию снова. Второй способ - проектировать операции как PATCH с конкретными полями вместо POST с полным объектом, или использовать event sourcing на сервере.
Конфликты - отдельная история. Если пользователь редактировал документ офлайн, а другой пользователь тем временем изменил тот же документ на сервере, у нас конфликт.
Стратегии разрешения:
Last Write Wins - простейший вариант, часто неправильный. Теряются изменения одной из сторон. Подходит для некритичных данных вроде настроек.
Merge - для структурированных данных (текст, списки) можно применить three-way merge: база, версия клиента, версия сервера. Именно так работает Git. CRDT (Conflict-free Replicated Data Types) - более мощный подход, который используют, например, Figma и Linear для коллаборативного редактирования.
Ask the User - когда автоматическое слияние невозможно, нужно показать пользователю обе версии и дать выбор. Плохо, если это происходит часто, но иногда единственный честный вариант.
Я стараюсь проектировать очередь как конечный автомат: каждое действие имеет статус pending, syncing, synced или failed. Это упрощает отображение состояния в UI и логику повторных попыток.
Отображение «Pending» и «Failed» - разные состояния требуют разных решений
Это различие часто игнорируют, и зря. «Ожидает отправки» и «Не удалось отправить» - принципиально разные ситуации с точки зрения пользователя. В первом случае ничего делать не нужно, во втором - нужно принять решение.
Для pending подходит subtle-индикатор: серая галочка, часики, небольшой бейдж. Пользователь должен понимать, что действие зафиксировано, но ещё не синхронизировано. Не нужно кричать об этом - достаточно тихого сигнала.
Для failed нужна явная обратная связь. Красная иконка, inline-сообщение об ошибке, и - что особенно важно - возможность отмены. Если пользователь отправил сообщение, оно не дошло, и он видит статус failed, у него должна быть кнопка «Отменить» или «Попробовать снова». Без неё он застрял: действие как будто произошло, но на самом деле нет.
Undo для failed-состояний работает по принципу «отложенного удаления»: само действие остаётся в очереди, но помечается как отменённое. Это проще, чем откат состояния. Для чатов и лент - показывать failed-сообщения серым с иконкой ошибки и кнопкой повтора рядом. Это паттерн, который пользователи уже знают из WhatsApp и Telegram, не нужно изобретать велосипед.
Автоповтор с экспоненциальной задержкой
Наивная реализация повтора - это цикл с фиксированным интервалом. Проблема: если тысяча клиентов потеряла соединение одновременно (например, упал WiFi в офисе), то при восстановлении они все начнут долбить сервер синхронно. Это thundering herd, и он способен положить бэкенд.
Экспоненциальная задержка с джиттером решает эту проблему. Каждая следующая попытка ждёт вдвое дольше предыдущей, а случайный джиттер рассыхает запросы по времени. Вот реализация на Swift с async/await:
enum RetryError: Error { case maxAttemptsReached case cancelled } func withExponentialBackoff<T>( maxAttempts: Int = 5, baseDelay: TimeInterval = 1.0, operation: @escaping () async throws -> T ) async throws -> T { var attempt = 0 while attempt < maxAttempts { do { return try await operation() } catch { attempt += 1 guard attempt < maxAttempts else { throw RetryError.maxAttemptsReached } let delay = baseDelay * pow(2.0, Double(attempt - 1)) let jitter = TimeInterval.random(in: 0...delay * 0.3) try await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000)) } } throw RetryError.maxAttemptsReached }
Использование:
let result = try await withExponentialBackoff { try await apiClient.syncPendingActions() }
Для Combine-стека это выглядит иначе, но идея та же - .retry() в Combine не поддерживает задержку из коробки, поэтому нужен кастомный оператор или flatMap с Deferred + Future и DispatchQueue.asyncAfter.
Важный нюанс: повторы нужно останавливать, когда пользователь явно отменил действие или когда приложение ушло в фон. Иначе батарея и трафик улетают в никуда. Я обычно привязываю lifecycle повторов к жизненному циклу задачи в очереди и отменяю Task при деинициализации контроллера или view model.
Максимальное количество попыток - вопрос политики приложения. Для критичных транзакций (платёж, медицинские данные) - больше попыток и явное уведомление при исчерпании. Для некритичных (аналитика, логи) - меньше попыток, тихий fail.
Тестирование офлайн-сценариев
Здесь большинство команд срезают углы, и это заметно в продакшне. Тестирование офлайна - это не «выключить WiFi и посмотреть, что будет». Это систематическая проверка всех переходов между состояниями.
На iOS есть Network Link Conditioner - инструмент в Additional Tools for Xcode. Он позволяет симулировать разные качества сети: 100% потеря пакетов, высокий latency, нестабильное соединение. Устанавливается как системное расширение на Mac или прямо на устройство через Settings для реального железа. Я использую профиль «100% Loss» для теста полного офлайна и «Very Bad Network» для теста деградированного соединения - это разные UX-ситуации.
На Android аналог - adb shell с командами для управления состоянием сети:
# Отключить мобильный интернет adb shell svc data disable # Отключить WiFi adb shell svc wifi disable # Включить обратно adb shell svc data enable adb shell svc wifi enable
Для веба - Chrome DevTools, вкладка Network, тротлинг до «Offline» или кастомные профили.
Но помимо инструментов, важна методология. Нужно тестировать конкретные сценарии: что происходит, если соединение пропадает в процессе загрузки? В процессе отправки формы? Что если пользователь закрыл приложение, пока действие ждало в очереди, и открыл его снова? Что если токен авторизации протух за время офлайна?
Последний сценарий - особенно коварный. Пользователь возвращается онлайн, очередь начинает синхронизацию, получает 401 Unauthorized, и теперь у нас конфликт между «нужно повторить» и «нужно сначала обновить токен». Это нужно тестировать отдельно и явно обрабатывать в коде. В комментариях к статье хотелось бы услышать вашу версию обработки такой ситуации.
Unit-тесты для логики очереди стоит писать с mock-сетевым слоем, который позволяет контролировать успех/провал запросов. XCTest позволяет это сделать через протоколы и dependency injection - никакого URLSession напрямую в продакшн-коде.
Когда предупреждать о потере данных
Это самый болезненный сценарий, и он требует честности по отношению к пользователю. Если данные могут быть потеряны - нужно об этом предупредить. Не после факта, а до.
Два типичных случая. Первый: пользователь пытается выйти из несохранённого документа офлайн. Классический диалог «Сохранить / Не сохранять / Отмена» здесь недостаточен - нужно явно сказать: «У вас есть несохранённые изменения. Они не смогут быть синхронизированы прямо сейчас и сохранятся только локально». Это честнее.
Второй случай: когда очередь синхронизации слишком выросла и мы не уверены, что все данные переживут очистку кэша или обновление приложения. Здесь нужна проактивная нотификация - не обязательно пуш, но хотя бы in-app banner при следующем открытии.
Отдельная ситуация - критические данные, которые вообще нельзя сохранять только локально: медицинские записи, финансовые транзакции, юридически значимые подписи. На мой взгляд, для таких кейсов правильный UX - заблокировать действие при отсутствии сети и чётко объяснить почему. Да, это неудобно. Но потерянная транзакция или неверно синхронизированная медкарта - несравнимо хуже.
Общий принцип, которым я руководствуюсь: пользователь должен иметь возможность принять информированное решение. Если мы не можем гарантировать сохранность данных - это нужно сказать явно и дать выбор: ждать сети, сохранить локальную копию с риском, отменить действие. Молчание здесь - это ложь.
Хорошо реализованный офлайн-режим - это одно из тех мест, где приложение либо зарабатывает долгосрочное доверие пользователя, либо теряет его навсегда. Потерянное сообщение, незапомненная форма, молчащий интерфейс в метро - всё это копится. Инвестиции в офлайн-UX окупаются не метриками, а репутацией.
Комментарии (6)

nerudo
02.03.2026 08:21Для тех, кто придумал "Что-то пошло не так" в новомодных приложениях котел уже греют?

Sozeko
02.03.2026 08:21С этим трендами очень актуальным становится продумать как будет жить приложение в offline

NutsUnderline
02.03.2026 08:21и года не прошло как начали догадываться. и без интернетов то непонятно: накой вообще новая учетка на всякий чих (приложение). а они еще и гонят меабайт данных там где требуется 100 байт передать

tanderus
02.03.2026 08:21Спасибо за статью эту и предыдущие! Мне очень нравится Ваш слог, пожалуйста, продолжайте :)
Особенно нравится стиль перехода к выводам почти в каждом абзаце и, главное, их меткость.По этой статье пара предложений:
init() { monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isConnected = path.status == .satisfied } } monitor.start(queue: queue) }1) Оставить один стиль, не смешивая GCD и async-await: в дальнейшем в статье последний используется;
2) Не в init'е стартовать, а отдельными методами рулить старт/стоп
Суммарно приблизительный вариант "быстро-на-коленке"
@Observable final class NetworkMonitor { // Добавил private(set) ибо не считаю, что нужно разрешать извне менять состояние private(set) var isConnected: Bool // либо не Never, а ошибку наружу давать, если потребуется @ObservationIgnored private var monitoringTask: Task<Void, Never>? @ObservationIgnored private var monitor: NWPathMonitor init(monitor: NWPathMonitor) { // возможно, DI Monitor'а под протоколом self.monitor = monitor } func startMonitoring() { if monitoringTask != nil { return } monitoringTask = Task(priority: .high /* или другой */) { [weak self] in guard let monitor = self?.monitor else { return } // Ключевой момент: NWPathMonitor.Iterator: AsyncIteratorProtocol for await path in monitor { guard let self = self, !Task.isCancelled else { return } self.isConnected = path.status == .satisfied } } } func stopMonitoring() { monitoringTask?.cancel() } deinit { stopMonitoring() } }Нужен персистентный store: Core Data, SQLite, или хотя бы
UserDefaultsдля простых случаев.3) Некритично: предлагаю упомянуть как SwiftData, так и NSPersistentContainer (если всё-таки Core), как сильно упрощающие жизнь в простых же сценариях.
По Вашему запросу:
Это нужно тестировать отдельно и явно обрабатывать в коде. В комментариях к статье хотелось бы услышать вашу версию обработки такой ситуации.
Это от бизнес-требований и критичности происходящего:
1) банковские приложения при протухании токена вовсе не дадут операцию "протыкать", ибо тебя уже выкинуло на экран авторизации;
2) я б UX-ово предпочёл явное а-ля "для операции (еёКороткоеОписание) необходимо авторизоваться" для первой попавшейся из очереди, а в последующих протухших также сразу обновить токен после успешной ре-авторизации

SHK83 Автор
02.03.2026 08:21Спасибо за статью эту и предыдущие! Мне очень нравится Ваш слог, пожалуйста, продолжайте :)Особенно нравится стиль перехода к выводам почти в каждом абзаце и, главное, их меткость.
Вот за это прям низкий поклон. А то меня тут уже нейросетью пару раз обозвали. Слог из "прошлой" жизни остался, как и структурирование текстов - юрист я по первому образованию. И опыт написания неимоверного объема документов из прошлого сказывается - пишу легко и быстро, мыли на бумагу удобно ложатся. Ну и выводы в каждом разделе оттуда же.
1) Оставить один стиль, не смешивая GCD и async-await: в дальнейшем в статье последний используется;
Согласен, кода надергал из разных проектов, потому и каша (не уследил). Стараюсь в статье подчеркивать, что это всего лишь примеры, но чаще забываю.
2) Не в init'е стартовать, а отдельными методами рулить старт/стоп
Ну а почему бы и да? Хороший пример, соглашусь.
3) Некритично: предлагаю упомянуть как SwiftData, так и NSPersistentContainer (если всё-таки Core), как сильно упрощающие жизнь в простых же сценариях.
чуть позже добавлю, спасибо
1) банковские приложения при протухании токена вовсе не дадут операцию "протыкать", ибо тебя уже выкинуло на экран авторизации;
2) я б UX-ово предпочёл явное а-ля "для операции (еёКороткоеОписание) необходимо авторизоваться" для первой попавшейся из очереди, а в последующих протухших также сразу обновить токен после успешной ре-авторизации
Тут можно подискутировать немного. Я сам разработчик банковского приложения (и сейчас, и ранее).
У нас сейчас два подхода к обновлению токенов:
протухший — сразу переводим пользователя на авторизацию. Тут нет вариантов что-то протыкать, юзер неизбежно должен авторизоваться снова. Ну и еще нужно не забывать, что у банковских приложение есть запросы на сервер вне авторизованного контекста - информация о продуктах (не персонифицированная), адреса, контакты и много другого. Я это к тому, что не всегда пользователя перекидывает на авторизую. Но это, опять же, редкие случаи (разработчикам проще написать общую логику "протухания" токенов, не учитывая контекст).
протухший — не трогаем юзера до момента отправки первого запроса на сервер. То есть у юзера есть возможность пройти заполнение анкеты до последнего шага и закэшировать результат его действий по вводу данных (разработчики чаще таким не заморачиваются).
В ответ на первое утверждение могу сказать, что банковские приложения разные: некоторые могут и дать юзеру возможность попользоваться функциональностью без перекидывания на экран авторизации. Ну а после авторизации можно же вернуть юзера обратно — на то место, где он остановился.
Ну а на второе утверждение — да, принято, хороший вариант.
Спасибо за ваши предложения и комментарий. Думаю, многим будет полезно.
losse_narmo
Вижу что мобильные приложения, там полегче в плане с "вы оффлайн".
Но я никогда не забуду, как клиент нам звонил и жаловался, что не работает наш сайт, а в итоге оказалось, что у них со вчера нет света в офисе, он сидит с ноутбука. Но интернет воткнул офисный, который естественно не работает.