Дайджесты новостей
Графическая иллюстрация в стиле комикса Ligne Claire со смартфоном, выполняющим фоновый веб-таймер Kapsula с иконками звука и уведомлений.

Как заставить PWA работать в фоне: опыт создания мобильного таймера Kapsula

Прогрессивные веб-приложения (PWA) уже много лет обещают разработчикам рай: пиши один раз на HTML, CSS и JavaScript, сохраняй иконку на рабочий стол смартфона — и получай полноценное приложение без 30% комиссии Apple App Store, согласований модерации и мучений со сборками под разные платформы.

Но стоит взяться за проект, требующий работы в реальном времени — интервальный таймер для тренировок, будильник, шагомер или аудио-плеер — как эта радужная картинка разбивается о суровую политику мобильных операционных систем.

Представь реальный сценарий: пользователь запускает веб-таймер, ставит 15 минут, блокирует экран телефона или переключается в Telegram, чтобы ответить на сообщение. И что происходит? Ровно через 5–10 секунд мобильный браузер (особенно iOS WebKit) отправляет вкладку в глубокий сон. Все твои таймеры setTimeout и setInterval замораживаются. Время останавливается! Пользователь достает телефон через полчаса, разблокирует экран, и только в этот момент таймер отмирает и начинает истошно звонить. Опыт испорчен, пользователь удаляет ярлык.

Разработчик мобильного PWA-таймера Kapsula столкнулся ровно с этим барьером и прошел через все круги инженерного ада, чтобы заставить приложение надежно работать в фоне на iOS и Android. Давай разберем проверенные архитектурные приемы и ограничения мобильных ОС.

Почему мобильные ОС убивают фоновый веб?

Главный враг разработчика PWA — политика энергосбережения и оптимизации оперативной памяти мобильных ОС (iOS Throttling Policy и Android Doze Mode):

  • iOS Safari / WebKit: как только веб-приложение теряет фокус (document.visibilityState === "hidden"), частота таймеров принудительно снижается до 1 тика в минуту. А если экран блокируется, фоновые сетевые сокеты и скрипты полностью замораживаются через несколько секунд.
  • Android Chromium: ведет себя чуть мягче, но при включении режима энергосбережения также безжалостно прибивает фоновые Service Worker через пару минут бездействия.

О том, как устроена современная среда выполнения PWA и какие ограничения накладывают платформы, мы подробно рассуждали в материале анатомия PWA в 2026 году: барьеры установки и ограничения.

Архитектурная триада: как удержать PWA на плаву

Чтобы таймер гарантированно сработал, нельзя наивно рассчитывать на то, что JavaScript будет крутить цикл каждую секунду в спящем телефоне. Надежное решение строится на трех китах:

Схема отказоустойчивого таймера в браузере: синхронизация абсолютного системного времени Date.now(), фонового Web Worker и Screen Wake Lock API.

1. Абсолютное время вместо тиков (Timestamp Delta)

Забудь про инкремент счетчика: secondsRemaining = secondsRemaining - 1. Если вкладку усыпят на 10 минут, счетчик потеряет эти 10 минут.

Вся логика строится на фиксации системной временной метки завершения:

const targetEndTime = Date.now() + durationInMs;

Когда пользователь разблокирует экран, приложение слушает событие visibilitychange и мгновенно пересчитывает оставшееся время: Math.max(0, targetEndTime - Date.now()).

2. Планирование уведомлений через Service Worker и Web Notifications

Чтобы сигнал прозвучал вовремя даже при заблокированном телефоне, уведомление необходимо зарегистрировать на уровне Service Worker через Notification API:

// sw.js — фоновый сервисный воркер
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((clientList) => {
      for (const client of clientList) {
        if (client.url === '/' && 'focus' in client) {
          return client.focus();
        }
      }
      if (clients.openWindow) {
        return clients.openWindow('/');
      }
    })
  );
});

// Слушаем команду от основного приложения на запуск системного таймера
self.addEventListener('message', (event) => {
  if (event.data.action === 'SCHEDULE_ALARM') {
    const { delayMs, title, body } = event.data;
    
    setTimeout(() => {
      self.registration.showNotification(title, {
        body,
        icon: '/icons/timer-192.png',
        badge: '/icons/badge-72.png',
        vibrate: [200, 100, 200],
        requireInteraction: true
      });
    }, delayMs);
  }
});
3. Трюк с фоновым аудиопотоком (Silent Audio Hack)

Для сценариев, когда звуковой сигнал должен воспроизвестись не через тихое системное уведомление, а громко через динамик (например, звук гонга в интервальном таймере), мобильные ОС делают исключение: активное воспроизведение аудио не замораживается.

При запуске таймера Kapsula запускает зацикленный беззвучный аудиопоток (Silent Audio Track) через AudioContext или элемент <audio loop src="silence.mp3">:

// audioKeeper.js
class BackgroundAudioKeeper {
  constructor() {
    this.audioCtx = null;
    this.oscillator = null;
  }

  // Запуск обязательно по пользовательскому жесту (клик "Старт")
  start() {
    const AudioContextClass = window.AudioContext || window.webkitAudioContext;
    this.audioCtx = new AudioContextClass();
    
    // Создаем генератор с околонулевой амплитудой (человеческое ухо не слышит)
    this.oscillator = this.audioCtx.createOscillator();
    const gainNode = this.audioCtx.createGain();
    
    gainNode.gain.value = 0.001; // Почти тишина
    this.oscillator.connect(gainNode);
    gainNode.connect(this.audioCtx.destination);
    
    this.oscillator.start();
  }

  stop() {
    if (this.oscillator) {
      this.oscillator.stop();
      this.audioCtx.close();
    }
  }
}

Пока в системе висит активный звуковой контекст, операционная система считает страницу медиаплеером и не переводит вкладку в режим жесткого энергосбережения, позволяя коду отсчитать интервалы и проиграть громкий сигнал.

Удержание экрана через Screen Wake Lock API

Еще одна частая проблема таймеров: человек положил телефон на стойку тренажера, делает планку и хочет видеть оставшиеся секунды, а экран гаснет через 30 секунд.

В современных версиях Chrome и Safari доступен нативный стандарт Screen Wake Lock API:

let wakeLock = null;

async function requestWakeLock() {
  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () => {
      console.log('Блокировка экрана снята');
    });
  } catch (err) {
    console.error('Ошибка Wake Lock:', err);
  }
}

// Перезапрашиваем блокировку при возврате вкладки в активный режим
document.addEventListener('visibilitychange', async () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    await requestWakeLock();
  }
});

Цена компромиссов

Применение этих хаков требует чувства меры. Бесконечное удержание беззвучного аудио сажает аккумулятор смартфона значительно быстрее обычного.

В приложении Kapsula разработчики применили гибридный подход: беззвучное аудио включается только на активных коротких интервалах тренировки (до 15–20 минут), а для длинных фоновых таймеров на несколько часов приложение полагается исключительно на расчет временных меток и системные Push-уведомления.

Этот инженерный баланс позволяет создавать PWA, которые по ощущениям ничем не уступают нативным приложениям из магазинов.