ESPN DeportesMonchi se disculpa por pedir Balón de Oro para LamineESPNHarbaugh offers rare critique of struggling QB Herbert: 'Be better'The Jerusalem PostWATCH: 'Don't mess with us': Netanyahu warns enemies may attack Israel ahead of electionBollywood HungamaJubin Nautiyal welcomes first child with wife after intimate wedding, shares update: “Mom and baby are back home”Daily MaverickTHE CONVERSATION: New world map makes Africa look bigger – What’s the fuss about? Cartographers explainRTP DesportoBrasil vence Austrália com Circati a marcar e Irankunda a cometer penáltiInquirerMost wanted person in Ilocos Sur town fallsBusiness AMRusland wil dit jaar nieuwe ballistische raket in dienst nemen met een bereik van 800 kmThe RegisterApple patches CoreGraphics zero-day already exploited in targeted attacksThe Hollywood ReporterHow a Microdramas Director Landed Her First Feature Film GigDeadlineLauren Cohan & Jake Epstein To Co-Star In Eric Stoltz Directed Rom-Com ‘Both Sides Now’The South AfricanPowerBall Xtra: R21 million up for grabs – plus guaranteed winner twist
The Daily Newsstand · Free, Always
Tuesday, September 29, 2026

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

Translate

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

Для этого странице нужно ответить на, казалось бы, тривиальный вопрос: есть ли сейчас сеть? На iPhone ответ оказался неожиданным. Ниже — как за один вечер мы собрали целую коллекцию граблей, что при этом выяснилось про navigator.onLine, таймауты и service worker и как повторить главный эксперимент у себя за пять минут.

Что хотели

Два места в интерфейсе, которые зависят от состояния сети:

  1. Плашка на главной. Пока сеть есть — «Интернет пока есть, включите авиарежим». Как только человек включил авиарежим из «Пункта управления» — «Интернета нет, а сайт работает» и кнопка «Посчитать пример».

  2. Экран после подсчёта. «Посчитано в авиарежиме» и время подсчёта. Здесь у нас правило: говорим это только наверняка. Сети не было в начале и не появлялась, пока считали. Ложное «посчитано без интернета» хуже, чем отсутствие экрана: оно подрывает то самое доверие, ради которого затевалось.

Попытка 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 сразу, а не после перезагрузки.

Как проверить у себя

  1. Не верьте зелёной галочке в DevTools → Offline. Он проверяет «сеть отказала», а не «сеть молчит».

  2. Поднимите сервер-«чёрную дыру» (пример выше) и проверьте страницу против него. Заодно увидите, у каких запросов нет верхней границы ожидания.

  3. Проверьте на живом устройстве и смотрите в логи сервера. Пачка одинаковых запросов, которая приходит в момент возвращения сети, — признак того, что запросы висели, а не падали.

  4. Пройдите путь новичка: чистый профиль браузера, первый визит, сразу авиарежим. Не после второй перезагрузки, а сразу.

  5. Сборка должна падать, если список предзагрузки указывает на несуществующий файл.

Полный скрипт эксперимента (около ста строк, Node и Playwright) я выложил в гист. Он запускается одной командой и печатает те же числа, что в статье.

Что мы не знаем

  • Всё сказанное про iPhone — наблюдение с двух телефонов (iPhone 15 Pro с iOS 26.5.2 и iPhone 15 с iOS 26.5.1) и одна строка лога. Мы не проверяли другие версии iOS, Android и случай, когда после включения авиарежима вручную включают Wi-Fi.

  • Механизма мы не знаем. Из наших данных следует поведение, но не его причина.

  • Цена правила «две секунды» — это ложное «сети нет» на очень медленной сети. Мы приняли её сознательно: для нашей задачи ошибиться в эту сторону лучше, чем в другую.

  • Опрос каждые 1,5 секунды — это трафик и батарея. Опрос включён только пока открыто окно «Проверить»; на остальных экранах сеть проверяется при появлении экрана и возвращении на вкладку.

Итог

Четыре правила, которые мы взяли из этого вечера:

  1. navigator.onLine === true ничего не гарантирует, а на iPhone в авиарежиме вообще не отражает реальность.

  2. Для страницы «сети нет» — это и ошибка, и тишина. Тишину нужно уметь считать за ответ.

  3. У каждого fetch в возможно-офлайн-сценарии должна быть верхняя граница ожидания.

  4. Список предзагрузки service worker'а — часть сборки, и сборка должна падать, если он неверен.

А как вы определяете офлайн? Кто-нибудь сталкивался с зависающими запросами на iOS иначе, чем у нас, или знает, почему Safari так делает? Буду рад ссылке на документацию или на баг в WebKit.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.