«Интернета нет — а сайт работает»: как мы учили PWA замечать авиарежим на iPhone

Мы делаем пет-проект «Чатограмма»: он считает статистику переписки прямо в браузере, файл с перепиской на сервер не уходит. Такое обещание проще всего проверить без чтения политик: включить авиарежим и посмотреть, посчитается ли. Мы даже вынесли это в интерфейс: если сети нет, главная сама меняется на «Интернета нет — а сайт работает», а после подсчёта показывается «Посчитано в авиарежиме».
Для этого странице нужно ответить на, казалось бы, тривиальный вопрос: есть ли сейчас сеть? На iPhone ответ оказался неожиданным. Ниже — как за один вечер мы собрали целую коллекцию граблей, что при этом выяснилось про navigator.onLine, таймауты и service worker и как повторить главный эксперимент у себя за пять минут.

Что хотели
Два места в интерфейсе, которые зависят от состояния сети:
Плашка на главной. Пока сеть есть — «Интернет пока есть, включите авиарежим». Как только человек включил авиарежим из «Пункта управления» — «Интернета нет, а сайт работает» и кнопка «Посчитать пример».
Экран после подсчёта. «Посчитано в авиарежиме» и время подсчёта. Здесь у нас правило: говорим это только наверняка. Сети не было в начале и не появлялась, пока считали. Ложное «посчитано без интернета» хуже, чем отсутствие экрана: оно подрывает то самое доверие, ради которого затевалось.
Попытка 0: navigator.onLine и события
Первое, что приходит в голову, и первое, что мы сделали:
let online = navigator.onLine;
addEventListener('online', () => (online = true));
addEventListener('offline', () => (online = false));В Chrome с включённым Offline в DevTools всё работало идеально: onLine становился false, приходило событие offline, плашка менялась. Мы проверили на iPhone (потом то же самое повторилось на втором, с другой версией iOS). Включили авиарежим — ничего. Плашка так и говорила «Интернет пока есть».
В документации MDN про navigator.onLine сказано осторожно: значению false можно доверять, а true не означает, что интернет действительно есть. На iPhone мы не получили даже честного false: в авиарежиме Safari оставлял navigator.onLine === true и не присылал событие offline.

Попытка 1: спросим сеть сами
Если браузер не знает, спросим у сети. Крошечный файл version.json с нашего домена, мимо HTTP-кэша и мимо service worker:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
try {
await fetch(`/version.json?ping=${Date.now()}`, { cache: 'no-store', signal: controller.signal });
set(true); // любой ответ — сеть есть
} catch {
if (controller.signal.aborted) return null; // не успели — не знаем, состояние не меняем
set(false); // сетевая ошибка — сети нет
}Одно решение здесь было сознательным: таймаут мы посчитали за «не знаем». Правило «говорить „без интернета“ только наверняка» требовало не путать медленную сеть с отсутствием сети.
Проверили на iPhone. Плашка не изменилась. Но в логе nginx после того, как мы выключили авиарежим, обнаружилось странное: пачка запросов version.json?ping=… в одну секунду.
Что на самом деле делает iPhone
Запрос, отправленный в авиарежиме, не завершался ошибкой. Он висел: ни ответа, ни TypeError: Failed to fetch. А когда сеть вернулась, накопившиеся запросы ушли на сервер разом. Наша проверка честно ждала 4 секунды, получала таймаут, писала себе «не знаю» и оставляла плашку как есть. Раз за разом, каждые 1,5 секунды.
Почему Safari так делает, не знаю. Есть гипотеза: система держит запрос «до появления связи», а не отклоняет его. Но это гипотеза, а не знание, документации на такое поведение я не нашёл.
Зато поведение легко воспроизвести без iPhone. Нужен сервер, который принимает соединение и молчит:
http.createServer((req, res) => {
if (req.url === '/hang') return; // принял запрос и молчит — как iPhone «до появления сети»
res.end('{}');
});Я открыл страницу в Chromium под управлением Playwright и сравнил два «нет сети»:
DevTools → Offline (в Playwright —
context.setOffline(true)):navigator.onLineсразуfalse, событиеofflineпришло,fetchупал с ошибкой за 7,5 мс.Сервер-«чёрная дыра»:
navigator.onLineостаётсяtrue, событий нет,fetchчерез 10 секунд всё ещё висит.
Из этого следует неприятный вывод: режим Offline в DevTools скрывает эту проблему целиком. Всё, что вы проверили в нём, «работает», а у пользователя на iPhone нет. Мы не нашли бы ошибку, если бы не проверили на телефоне.
Попытка 2: тишина — тоже ответ
Если запрос к собственному серверу не завершился за две секунды, для страницы это и есть «сети нет». Меняем всего одну ветку:
const PROBE_TIMEOUT_MS = 2000;
let inFlight: Promise<boolean> | null = null;
/** true — ответ пришёл, false — ошибка или ответа нет за 2 с. Одновременно — не больше одного запроса. */
export function probeOnline(): Promise<boolean> {
if (navigator.onLine === false) { set(false); return Promise.resolve(false); }
inFlight ??= (async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
await fetch(`/version.json?ping=${Date.now()}`, { cache: 'no-store', signal: controller.signal });
set(true); return true;
} catch {
set(false); return false; // и ошибка, и тишина
} finally {
clearTimeout(timer);
inFlight = null;
}
})();
return inFlight;
}Три решения, каждое из которых что-то стоит.
Две секунды. Это компромисс между быстротой реакции и ложными срабатываниями. Окно «Проверить» опрашивает сеть каждые 1,5 секунды, значит, по расчёту плашка меняется через 1,5–3,5 секунды после включения авиарежима. В проверке на Chromium с зависающими запросами она сменилась за 3,7 секунды, то есть чуть позже расчёта.
Цена ошибки. Я измерил её в том же эксперименте: сервер отвечает через заданное время, проба ждёт две секунды.
Ответ сервера через | 0,5 с | 1,5 с | 2,5 с | 4 с |
|---|---|---|---|---|
Вердикт | сеть есть | сеть есть | «сети нет» | «сети нет» |
На очень медленной, но живой сети окно «Проверить» на пару секунд покажет «Интернета нет», а следующий успешный ответ это исправит. Для «посчитано в авиарежиме» ошибка в эту сторону не страшна: там мы проверяем сеть в начале подсчёта и дополнительно следим, не появилась ли она по ходу.
Не больше одного запроса. Проба опрашивает сеть каждые 1,5 секунды, а висящий запрос живёт дольше. Отменяет ли iOS такой запрос по abort(), мы не проверяли, а в логе после авиарежима была пачка, поэтому лишних запросов не плодим: пока одна проверка не закончилась, новых не начинаем.
Мимо кэша и мимо service worker. cache: 'no-store' обходит HTTP-кэш, а в самом service worker есть исключение для /version.json: проба должна дойти до сети, а не до кэша.


Побочные жертвы: всё, что ходит в сеть
Самое интересное началось после того, как плашка заработала. Оказалось, что если сеть не отказывает, а молчит, ломается всё остальное, что в неё ходит.
Подсчёт вставал навсегда. Для «ваших словечек» воркер подгружает частотный словарь — файл на 2 МБ со своего домена, заранее в кэш он не кладётся. Без сети fetch не падал, а висел — и пример в авиарежиме навсегда застревал на «Считаем итоги». Лечится верхней границей ожидания:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch('/dict/ru-freq.cfd1', { signal: controller.signal });
clearTimeout(timer); // заголовки пришли — загрузку уже не рвём
...Нюанс: таймер снимается, как только пришли заголовки ответа, поэтому таймаут ограничивает ожидание начала ответа, а не скачивание двух мегабайт по медленной сети.
«Посчитано за 4 секунды» вместо 0,4. Даже с таймаутом подсчёт в авиарежиме занимал около четырёх секунд: две с половиной из них воркер зря ждал словарь, который не придёт. Теперь, если страница уже знает, что сети нет, она не ходит за словарём, а берёт его только из кэша service worker, а время подсчёта фиксирует до проверки сети:
if (offline) {
const cached = await caches.match('/dict/ru-freq.cfd1');
return cached ? decodeFrequencyDictionary(await cached.arrayBuffer()) : undefined;
}На iPhone «за 4 секунды» превратилось в «за 0,4».
Общий вывод оказался шире, чем про Safari: у любого fetch в сценарии «сети, возможно, нет» должна быть верхняя граница ожидания. «Ошибка или ответ» — это картина мира, в которой сеть либо есть, либо честно отказывает. Реальная сеть иногда молчит.
Отдельная история: service worker, которого не было
Определять авиарежим бессмысленно, если сайт без сети не открывается. Тем же вечером выяснилось, что service worker не устанавливался у новых посетителей, и никакой ошибки мы не видели.
Список файлов для предзагрузки собирается при сборке Vite из бандла и записывается в sw.js. В установке всего одна строка:
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(PRECACHE).then((cache) => cache.addAll(FILES)).then(() => self.skipWaiting()));
});cache.addAll — «всё или ничего»: один ответ не 200, и весь install завершается ошибкой, а service worker не ставится. У нас в список попал certificate-<хеш>.js, которого не существовало. Модуль certificate.css — чисто стилевой, Vite при сборке создаёт для него пустой JS-чанк, а плагин vite:css-post потом его удаляет. Наш плагин собирал список до этой чистки, поэтому в sw.js оказалась ссылка на несуществующий файл, nginx отвечал 404, и install падал. У тех, у кого service worker уже стоял, всё работало. У новых посетителей сайт без сети не открывался вообще.
Лечение — два изменения:
generateBundle: {
order: 'post', // после встроенных плагинов Vite: пустые чанки уже удалены
handler(_options, bundle) { precached = [ /* список из bundle + public */ ]; ... },
},
// Сторож: каждый файл из списка должен лечь на диск — иначе service worker не установится.
writeBundle(options) {
const dir = options.dir ?? '';
const missing = precached.filter((name) => name !== '/' && !existsSync(join(dir, name.slice(1))));
if (missing.length) throw new Error(`sw.js: в списке предзагрузки файлов нет в сборке — ${missing.join(', ')}`);
},Сторож важнее порядка. Порядок починил конкретную ошибку, а сторож превращает тихий отказ в громкую поломку сборки: следующий такой файл не доедет до продакшена. Отдельно: 404.html в список класть нельзя — nginx у нас отдаёт её только со статусом 404, и это тоже ломает addAll.

Есть и вторая мелочь того же рода. Страница первого визита открывается до установки service worker и им не управляется. Если человек сразу включил авиарежим (а окно «Проверить» ровно это и советует), файлы экрана примера, которые подгружаются по нажатию, взять неоткуда: worker в игру ещё не вступил. Помогает clients.claim() в обработчике activate, и страница первого визита переходит под управление worker сразу, а не после перезагрузки.
Как проверить у себя
Не верьте зелёной галочке в DevTools → Offline. Он проверяет «сеть отказала», а не «сеть молчит».
Поднимите сервер-«чёрную дыру» (пример выше) и проверьте страницу против него. Заодно увидите, у каких запросов нет верхней границы ожидания.
Проверьте на живом устройстве и смотрите в логи сервера. Пачка одинаковых запросов, которая приходит в момент возвращения сети, — признак того, что запросы висели, а не падали.
Пройдите путь новичка: чистый профиль браузера, первый визит, сразу авиарежим. Не после второй перезагрузки, а сразу.
Сборка должна падать, если список предзагрузки указывает на несуществующий файл.
Полный скрипт эксперимента (около ста строк, Node и Playwright) я выложил в гист. Он запускается одной командой и печатает те же числа, что в статье.
Что мы не знаем
Всё сказанное про iPhone — наблюдение с двух телефонов (iPhone 15 Pro с iOS 26.5.2 и iPhone 15 с iOS 26.5.1) и одна строка лога. Мы не проверяли другие версии iOS, Android и случай, когда после включения авиарежима вручную включают Wi-Fi.
Механизма мы не знаем. Из наших данных следует поведение, но не его причина.
Цена правила «две секунды» — это ложное «сети нет» на очень медленной сети. Мы приняли её сознательно: для нашей задачи ошибиться в эту сторону лучше, чем в другую.
Опрос каждые 1,5 секунды — это трафик и батарея. Опрос включён только пока открыто окно «Проверить»; на остальных экранах сеть проверяется при появлении экрана и возвращении на вкладку.
Итог
Четыре правила, которые мы взяли из этого вечера:
navigator.onLine === trueничего не гарантирует, а на iPhone в авиарежиме вообще не отражает реальность.Для страницы «сети нет» — это и ошибка, и тишина. Тишину нужно уметь считать за ответ.
У каждого
fetchв возможно-офлайн-сценарии должна быть верхняя граница ожидания.Список предзагрузки service worker'а — часть сборки, и сборка должна падать, если он неверен.
А как вы определяете офлайн? Кто-нибудь сталкивался с зависающими запросами на iOS иначе, чем у нас, или знает, почему Safari так делает? Буду рад ссылке на документацию или на баг в WebKit.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.