javascript

Как «спят» вкладки в браузере

  • суббота, 23 мая 2026 г. в 00:00:04
https://habr.com/ru/companies/yoomoney/articles/1038288/

Привет! Меня зовут Костя, я разработчик интерфейсов в ЮMoney. В этой статье разбираю, почему вкладка после возврата из фона начинает вести себя странно: интерфейс подвисает, таймеры съезжают, события приходят пачкой.

Материал особенно пригодится тем, кто делает сложные SPA с realtime‑обновлениями, WebSocket и насыщенным UI — CRM, дашборды, платёжные сценарии.

Тема выросла из доклада, который я буду читать на Frontend Mix — бесплатном митапе ЮMoney для фронтенд‑разработчиков. Но здесь будет именно практический разбор: с примерами, кодом и объяснением того, как браузеры работают с неактивными вкладками.

В статье разберём:

  • как устроены Page Visibility API и Page Lifecycle API,

  • зачем браузеры ограничивают фоновые процессы,

  • что происходит при заморозке вкладок, системном сне и возврате страницы из BFCache,

  • чем отличаются Chrome, Safari и Firefox,

  • какие API уже устарели,

  • а какие подходы помогают делать интерфейсы стабильнее в реальных пользовательских сценариях.

На митапе обсудим практические нюансы и вопросы, которые чаще всего возникают у фронтенд-разработчиков при работе с фоновыми вкладками.

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

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

Для простых сайтов это незаметно. Но в CRM, чатах, платёжных сценариях и дашбордах такие оптимизации быстро становятся источником багов — особенно после долгого простоя вкладки, сна ноутбука или возврата через кнопку «Назад».

Кейс из реального проекта

В ЮMoney я работаю над CRM для контактного центра: операторы получают обновления по WebSocket и держат открытыми десятки вкладок на протяжении всей смены. Часть сотрудников работает на терминальных серверах, где ресурсы ограничены, — именно там браузерные ограничения проявляются особенно заметно.

Этот кейс — часть программы Frontend Mix, митапа ЮMoney про фронтенд, который пройдёт 28 мая онлайн и офлайн в Санкт‑Петербурге.

Помимо «спящих» вкладок, в программе ещё три доклада:

  • Spec‑Driven платформа для генерации писем с OpenAPI как единым источником правды и автогенерацией консистентного HTML.

  • Подключение модуля шумоподавления к рабочему месту оператора: React, WebSockets, WebRTC и архитектура модуля.

  • Круглый стол про AI во фронтенде — влияние нейросетей на рынок, разработку и образование.

Подробности и регистрация — на сайте Frontend Mix, ссылка в конце статьи.

С какой проблемой столкнулись

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

Снаружи это выглядело так:

  • оператор работает сразу с несколькими вкладками CRM и другими внутренними системами;

  • спустя время возвращается к тикету, чтобы продолжить обработку обращения;

  • интерфейс «оживает» не сразу: зависают спиннеры, пачкой приходят уведомления, а некоторые клики будто теряются.

Сервер отвечал стабильно, WebSocket не рвался, CPU не был постоянно перегружен. Общая закономерность была одна: проблема чаще возникала после того, как вкладка долго находилась в фоне.

Что происходило под капотом — и как мы это реконструировали

Картина оказалась такой:

  1. Оператор переключается на другую вкладку.

  2. Через некоторое время браузер замораживает фоновую вкладку для экономии ресурсов. WebSocket при этом остаётся «живым», и входящие события продолжают накапливаться в буфере.

  3. Оператор возвращается к вкладке.

  4. Браузер «размораживает» страницу, после чего накопившиеся события почти одновременно попадают в обработчики.

  5. На страницу обрушивается лавина вызовов API, повторных рендеров React и всплывающих уведомлений.

  6. На слабом железе интерфейс начинает ощутимо тормозить, а часть элементов перестаёт отвечать.

Все части системы по отдельности работали нормально. Проблема возникала в момент «пробуждения» вкладки: браузер разом отдавал накопленные события и получал всплеск нагрузки — и на слабых машинах этого хватало для заметного фриза.

Как мы это доказали

Гипотеза сложилась быстро: интерфейс тормозил именно при возврате.

Мы включили логирование событий жизненного цикла — visibilitychange, pagehide/pageshow, freeze/resume — и использовали chrome://discards, чтобы вручную замораживать вкладки.

Гипотеза подтвердилась: после resume браузер действительно разом отдавал накопившиеся WebSocket-события. Стало ясно, что причина — в самой модели работы браузера с фоновыми вкладками.

Решение (спойлер)

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

Page Visibility API

Базовый инструмент для работы с фоновыми вкладками. Даёт document.visibilityState со значениями "visible" / "hidden" и событие visibilitychange.

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    // Вкладка ушла в фон — останавливаем обновления
    pauseLiveUpdates();
    saveApplicationState();
  } else {
    // Вкладка снова активна
    resumeLiveUpdates();
    checkConnections();
  }
});

На мобильных устройствах это самый надёжный сигнал: beforeunload и unload там могут не сработать. Ограничение — API различает только «видно» и «не видно» и не говорит, была ли вкладка заморожена или уже выгружена из памяти.

Page Lifecycle API

Page Lifecycle API расширяет эту модель и описывает полный жизненный цикл страницы. Это спецификация WICG, инициированная Google, которая появилась в Chromium-браузерах начиная с Chrome 68.

Firefox и Safari не реализовали отдельные события freeze/resume, но используют похожие механики: троттлинг таймеров, заморозку и выгрузку вкладок. Далее использую терминологию Page Lifecycle API как общую модель, отмечая специфику Chromium там, где она есть.

Состояния страницы

Состояние

Описание

Таймеры

JS

Ресурсы

Active

Страница видна и имеет фокус.

Без ограничений

Работает

Нормальное

Passive

Страница видна, фокус — в другом окне.

Без ограничений

Работает

Нормальное

Hidden

Вкладка в фоне.

Троттлинг

Работает медленнее

Сниженное

Frozen

JS на паузе, колбэки не вызываются.

Не выполняются

Заморожен

Минимальное

Discarded

Полностью выгружена из памяти

Нет

Нулевое

Важные нюансы: в состоянии Frozen WebSocket может оставаться открытым, но обработчики onmessage не вызываются — поэтому после разморозки вкладка получает пачку накопившихся событий. Переход в Discarded происходит без событий: факт выгрузки можно определить только после перезагрузки через document.wasDiscarded.

Граф переходов между состояниями
Граф переходов между состояниями

Поэтому важное состояние лучше сохранять на visibilitychange, не дожидаясь freeze: переход Hidden → Discarded пользовательский код не увидит.

Полная таблица событий жизненного цикла

Событие

Объект-цель

Переход

Примечание

focus

DOM-элемент

Passive → Active

Только если у страницы не было фокуса.

blur

DOM-элемент

Active → Passive

Только если страница теряет фокус полностью.

visibilitychange

document

Passive ↔ Hidden

Основное событие для обнаружения ухода в фон.

freeze ⭐

document

Hidden → Frozen

Только Chromium 68+.

resume ⭐

document

Frozen → Hidden/Active

Только Chromium 68+.

pageshow

window

Frozen → Active

event.persisted = true при BFCache.

pagehide

window

Hidden → Frozen/Terminated

event.persisted = true при BFCache.

beforeunload

window

Hidden → Terminated

Только при пользовательском закрытии.

unload

window

Hidden → Terminated

⚠️ Устаревший, блокирует BFCache.

⭐ — события, добавленные Page Lifecycle API (Chromium only)

Определение текущего состояния программно

const getLifecycleState = () => {
  if (document.visibilityState === 'hidden') return 'hidden';
  if (document.hasFocus()) return 'active';
  
  return 'passive';
};

let currentState = getLifecycleState();

const logStateChange = (next) => {
  if (next === currentState) return;
  
  console.log(`[Lifecycle] ${currentState} → ${next}`);
  currentState = next;
};

['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach((type) => {
  window.addEventListener(type, () => logStateChange(getLifecycleState()));
});

document.addEventListener('freeze', () => logStateChange('frozen'));
window.addEventListener('pagehide', (event) => {
  logStateChange(event.persisted ? 'frozen' : 'terminated');
});

Механика троттлинга

Троттлинг в первую очередь затрагивает таймеры (setTimeout, setInterval), и политики браузеров становятся всё агрессивнее.

Базовый троттлинг

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

  • Chrome — примерно через 10 секунд в фоне;

  • Firefox — примерно через 30 секунд;

  • Safari — тоже использует троттлинг, но точные пороги официально не документированы.

Интенсивный троттлинг (Chrome 88+)

Дополнительный уровень — Intensive Wake Up Throttling (Chrome 88+). Включается при одновременном выполнении условий:

Условие

Значение

Время в фоне

Более 5 минут.

Аудио

Страница не воспроизводит звук последние 30 секунд.

WebRTC

Не используется.

Timer chain

Цепочка таймеров содержит ≥ 5 вызовов.

В этом режиме таймеры срабатывают не чаще 1 раза в минуту.

Обычный фон:   setInterval(fn, 1000) → ~1 раз/сек
Интенсивный:   setInterval(fn, 1000) → ~1 раз/мин

Бюджетная модель (Chrome и Firefox)

Chrome и Firefox также используют бюджетную модель: каждая фоновая вкладка получает ограниченный CPU-бюджет.

  • задача выполняется, только если бюджет неотрицательный;

  • время выполнения задачи вычитается из бюджета;

  • бюджет постепенно восстанавливается — примерно на 10 мс в секунду.

Пример: начальный бюджет 50 мс, бюджет пополняется на 10 мс/с:

Секунда

Пополнение

Бюджет до

Что выполнилось

Бюджет после

1

+10

60

T1 (15) + T2 (25)

20

2

+10

30

T1 (15) + T2 (25)

−10

3

+10

0

только T1 (15)

−15

4

+10

−5

ничего

−5

5

+10

5

только T1 (15)

−10

6

+10

0

только T1 (15)

−15

Вывод: чем больше таймеров и чем тяжелее каждая задача, тем быстрее становятся заметны задержки.

Что защищает вкладку от троттлинга

Фактор

Эффект

Воспроизведение аудио

Защита от интенсивного троттлинга.

Активный WebRTC

Защита от интенсивного троттлинга.

Web Locks API

Защита от заморозки.

Активный Service Worker

Частичная защита.

IndexedDB-соединение

Защита от заморозки в некоторых браузерах.

Web Push-уведомления

Защита от выгрузки.

Библиотека audio-context-timers

Защита от троттлинга через AudioContext (нестандартный приём).

Заморозка вкладки: все сценарии

Заморозка возможна в нескольких сценариях.

Сценарий 1: «Тихая» фоновая вкладка (Chrome на Android)

Мобильный Chrome замораживает фоновые вкладки, скрытые более 5 минут, при нехватке ресурсов.

Сценарий 2: режим энергосбережения (Chrome 133+, февраль 2025)

Chrome 133+ замораживает CPU-интенсивные фоновые вкладки при включённом режиме экономии заряда (Energy Saver).

Для заморозки одновременно должны выполняться условия:

  • вкладка скрыта и не воспроизводит звук более 5 минут;

  • вкладка потребляет значительные ресурсы CPU.

Вкладка не будет заморожена, если:

  • активно аудио- или видеосоединение через WebRTC;

  • используется Web Lock (navigator.locks.request);

  • открыто IndexedDB-соединение, блокирующее транзакцию за пределами группы вкладок.

Сценарий 3: выгрузка при нехватке памяти (Discarded)

При нехватке RAM браузер выгружает вкладку без событий. Факт выгрузки в Chrome определяется через document.wasDiscarded после следующей загрузки.

// При следующей загрузке страницы
if (document.wasDiscarded) {
  console.warn('Страница была выгружена браузером');
  const saved = sessionStorage.getItem('appState');
  if (saved) {
    restoreState(JSON.parse(saved));
  } else {
    initializeFromServer();
  }
}

Что происходит при заморозке

При наступлении заморозки:
  ✗ JS-код прекращает выполнение
  ✗ setTimeout / setInterval не срабатывают
  ✗ Колбэки Promise не вызываются
  ✓ WebSocket-соединение на уровне TCP может сохраняться
  ✗ Обработчики ws.onmessage всё равно не вызываются
  ✓ Сообщения накапливаются в буфере

При разморозке:
  ✓ JS возобновляется с того места, где остановился
  ✓ Накопленные WebSocket-сообщения обрабатываются разом
  ← Именно здесь чаще всего и появляются тормоза UI и гонки состояний: обработчики начинают быстро проигрывать накопленные «исторические» события, пока пользователь уже совершает новые действия — кликает по интерфейсу, меняет данные или запускает новые запросы.

Реагирование на заморозку (Chromium)

document.addEventListener('freeze', () => {
  // Внутри обработчика freeze работает только синхронный код
  sessionStorage.setItem('appState', JSON.stringify(getAppState()));
  sessionStorage.setItem('frozenAt', String(Date.now()));
  // Сетевые логи отправляем через beacon
  navigator.sendBeacon('/freeze-log', JSON.stringify({ at: Date.now() }));
});

document.addEventListener('resume', () => {
  const frozenAt = Number(sessionStorage.getItem('frozenAt'));
  const frozenFor = Date.now() - frozenAt;
  console.log(`Вкладка была заморожена ${frozenFor} мс`);
  reconnectWebSocket();
  if (frozenFor > 5_000) fetchMissedEvents(frozenAt);
  sendResumeLog();
});

Системный сон

Сон устройства — отдельный сценарий: ОС приостанавливает весь процесс браузера. После пробуждения надёжной точкой восстановления становится visibilitychange (hidden → visible). В этот момент полезно:

  • проверить состояние WebSocket-соединений;

  • запросить актуальные данные с сервера;

  • проверить метки времени и определить длительность сна.

let hiddenAt = null;

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    hiddenAt = Date.now();
    
    return;
  }
  
  if (hiddenAt === null) return;
  
  const timeAwayMs = Date.now() - hiddenAt;
  hiddenAt = null;
  console.log(`Вкладка отсутствовала ${timeAwayMs} мс`);
  
  if (timeAwayMs > 30_000) {
    verifyConnections();
    refreshStaleData();
  }
  
  if (timeAwayMs > 5 * 60_000) {
    forceFullRefresh();
  }
});

Как ведут себя разные браузеры

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

  • Chrome / Chromium — реализуют Page Lifecycle API (freeze/resume), а также предоставляют chrome://discards и инструменты DevTools для работы с BFCache. Именно Chromium-поведение чаще всего используют как эталон.

  • Firefox — не поддерживает Page Lifecycle API как отдельный набор событий, но использует похожие механики: бюджетную модель таймеров, троттлинг и выгрузку вкладок (about:unloads). Вместо freeze фактически применяется discard.

  • Safari (включая iOS) — одним из первых внедрил BFCache и агрессивную выгрузку вкладок при нехватке памяти. Page Lifecycle API и SharedWorker на iOS не поддерживаются, но троттлинг и заморозка фоновых вкладок работают очень активно.

Практический вывод один: нельзя рассчитывать на стабильные таймеры и непрерывный JavaScript в фоне. Для кросс-браузерной логики достаточно опираться на visibilitychange, pagehide/pageshow и PageLifecycle.js.

BFCache — мгновенная навигация

BFCache (Back-Forward Cache) — механизм мгновенного возврата «назад/вперёд» без перезагрузки. Браузер сохраняет снимок вкладки: DOM, JS heap, состояние форм, позицию скролла.

История поддержки

Браузер

Версия

Год

Safari

1.0

2002

Firefox

1.5

2005

Chrome

96

2021

По данным Chrome, каждая 10-я навигация на десктопе и каждая 5-я на мобильных — это переходы «назад/вперёд». BFCache делает их мгновенными и улучшает LCP.

Как работает BFCache
Как работает BFCache

Тайм-аут BFCache

Обычные страницы хранятся до ~10 минут, страницы с Cache-Control: no-store — до 3 минут.

BFCache и Cache-Control: no-store

Исторически Cache-Control: no-store отключал BFCache. С весны 2025 Chrome поддерживает BFCache даже для no-store, но страница всё равно не попадёт в кеш при открытых WebSocket, WebTransport или WebRTC.

Chrome удалит страницу из BFCache, если во время хранения:

  • изменились cookies или другие данные авторизации;

  • fetch или XHR вернули ответ с Cache-Control: no-store.

Подводные камни BFCache

1. useEffectне вызывается повторно

React-компоненты не монтируются заново, поэтому useEffect(fn, []) повторно не выполнится.

// ❌ Не сработает при восстановлении из BFCache
useEffect(() => {
  fetchData();
}, []);

// ✅ Обрабатываем BFCache явно
// (используем useLayoutEffect т.к. pageshow срабатывает рано)
useLayoutEffect(() => {
  const onPageShow = (event) => {
    if (event.persisted) fetchData();
  };
  
  window.addEventListener('pageshow', onPageShow);
  
  return () => window.removeEventListener('pageshow', onPageShow);
}, []);

2. Состояние возвращается устаревшим

BFCache возвращает страницу в том состоянии, в котором пользователь её покинул. Если данные изменились — UI покажет устаревшую картину. Особый кейс: если перед переходом на внешний URL показан полноэкранный прелоадер, при возврате пользователь получит «мёртвый» интерфейс.

3. Открытый WebSocket может исключить страницу из BFCache

По данным HTTP Archive, ~71% сайтов с WebSocket теряют совместимость с BFCache из-за открытых соединений.

// Практический шаблон, если вы хотите дружить с BFCache
window.addEventListener('pagehide', () => {
  if (ws) {
    ws.close(1000, 'pagehide');
    ws = null;
  }
});

// Переоткрываем только при возврате из BFCache
window.addEventListener('pageshow', (event) => {
  if (!event.persisted) return;
  ws = new WebSocket(WS_URL);
  setupWebSocketHandlers(ws);
  fetchMissedEvents();
});

Паттерн подходит, когда совместимость с BFCache важнее минимального числа WebSocket reconnect’ов.

Краткая таблица причин BFCache-несовместимости

Причина

Решение

unload-обработчик

Удалить, использовать pagehide.

Открытый WebSocket

Закрыть в pagehide, переоткрыть в pageshow.

Открытое IndexedDB-соединение

Закрыть в pagehide.

Активный Web Lock

Освободить в pagehide.

Открытый BroadcastChannel

Закрыть в pagehide.

Cache-Control: no-store

Частично: Chrome разрешил для большинства случаев.

Незавершённые fetch с телом

Использовать AbortController в pagehide.

beforeunload-обработчик

Добавлять только при реально несохранённых данных, сразу удалять.

Устаревшие API: чего избегать

unload — удалить навсегда

// ❌ Так делать нельзя
window.addEventListener('unload', saveData);

Проблемы использования события unload

  • Ненадёжность: не срабатывает при закрытии вкладки жестом «свайп» на iOS.

  • Блокировка BFCache: наличие обработчика unload делает страницу несовместимой с кэшем назад/вперёд (BFCache) в десктопных версиях Chrome и Firefox.

  • Устаревание: Google планирует полностью отказаться от этого события (deprecation).

Рекомендуемая замена:
Используйте комбинацию событий pagehide и visibilitychange.

beforeunload — только условно

// ❌ Нельзя добавлять безусловно — это блокирует BFCache
 window.addEventListener('beforeunload', handler);

 // ✅ Только пока есть несохранённые изменения, и сразу снимать
 onUserStartsEditing(() => {
   window.addEventListener('beforeunload', warnAboutUnsavedChanges);
 });

 onUserSavesChanges(() => {
   window.removeEventListener('beforeunload', warnAboutUnsavedChanges);
 });

Надёжная отправка данных при уходе

window.addEventListener('pagehide', () => {
  // Вариант 1: sendBeacon — простой, только POST, без своих заголовков
  navigator.sendBeacon('/analytics', JSON.stringify(analyticsData));
  
  // Вариант 2: fetch с keepalive — больше контроля
  fetch('/save-state', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(appState),
    keepalive: true, // запрос завершится даже после закрытия вкладки
  });
});

Лимит у обоих вариантов — 64 КБ. Большие payload стоит сжимать заранее.

Продвинутые паттерны

Батчинг WebSocket-сообщений

Идея — копить сообщения в Map по ключу и каждые 50 мс отдавать наружу одно — последнее по ключу:

const BATCH_DELAY_MS = 50;
const pending = new Map();
let timerId = null;

function batch(message, onFlush) {
  pending.set(message.type, message); // дедуп по ключу
  if (timerId !== null) return;
  
  timerId = setTimeout(() => {
    timerId = null;
    pending.forEach(onFlush);
    pending.clear();
  }, BATCH_DELAY_MS);
}

Батчинг не решает всё: если обработчик применяет payload напрямую к локальному состоянию, «последнее по ключу» сообщение может оказаться старше действия пользователя — UI откатится к устаревшим данным. Поэтому батчинг лучше сочетать с версионированием событий или повторной сверкой с сервером после resume и visibilitychange.

Lifecycle-обёртка для WebSocket

Удобно обернуть WebSocket в небольшой класс, централизованно реагирующий на visibilitychange, freeze и BFCache:

class LifecycleAwareWebSocket {
  #url;
  #ws = null;
  #hiddenAt = null;
  #onMessage;
  
  constructor(url, onMessage) {
    this.#url = url;
    this.#onMessage = onMessage;
    this.#setupLifecycle();
    this.#connect();
  }
  
  #connect() {
    if (this.#ws?.readyState === WebSocket.OPEN) return;
    this.#ws = new WebSocket(this.#url);
    this.#ws.onmessage = ({ data }) => this.#onMessage(JSON.parse(data));
  }
  
  #setupLifecycle() {
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.#hiddenAt = Date.now();
      } else {
        this.#onBecomeVisible();
      }
    });
    
    // BFCache
    window.addEventListener('pagehide', () => this.#ws?.close());
    window.addEventListener('pageshow', (e) => {
      if (e.persisted) this.#connect();
    });
    
    // Заморозка (Chromium)
    document.addEventListener('resume', () => {
      if (this.#ws?.readyState !== WebSocket.OPEN) this.#connect();
    });
  }
  
  #onBecomeVisible() {
    const timeAway = this.#hiddenAt ? Date.now() - this.#hiddenAt : 0;
    this.#hiddenAt = null;
    if (this.#ws?.readyState !== WebSocket.OPEN) this.#connect();
    if (timeAway > 30_000) fetchDelta(Date.now() - timeAway, this.#onMessage);
  }
}

SharedWorker: одно соединение на все вкладки

Можно вынести одно WebSocket-соединение в SharedWorker и раздавать события всем вкладкам:

// shared-ws-worker.js
let socket = null;
const ports = new Set();

self.addEventListener('connect', (event) => {
  const [port] = event.ports;
  ports.add(port);
  port.start();
  port.onmessage = ({ data }) => {
    if (data.type === 'CONNECT' && !socket) {
      socket = new WebSocket(data.url);
      socket.onmessage = ({ data: payload }) =>
        ports.forEach((p) => p.postMessage({ type: 'WS_MESSAGE', payload }));
    }
    
    if (data.type === 'SEND' && socket?.readyState === WebSocket.OPEN) {
      socket.send(data.payload);
    }
  };
});

// В основном скрипте каждой вкладки
const worker = new SharedWorker('/shared-ws-worker.js');
worker.port.start();
worker.port.postMessage({ type: 'CONNECT', url: 'wss://api.example.com/events' });
worker.port.onmessage = ({ data }) => {
  if (data.type === 'WS_MESSAGE') handleServerEvent(JSON.parse(data.payload));
};

⚠️ SharedWorker не поддерживается в Safari на iOS. Альтернатива — Service Worker или BroadcastChannel + Web Locks.

Service Worker как замена фоновой логики

Если фоновая логика должна переживать заморозку — переносите её в Service Worker. Он не замораживается и может обрабатывать push-уведомления и синхронизировать данные.

// Страница уведомляет Service Worker о своём состоянии
document.addEventListener('freeze', () => {
  navigator.serviceWorker.controller?.postMessage({
    type: 'PAGE_FROZEN',
    timestamp: Date.now(),
  });
});

// Внутри Service Worker можно проверить состояние клиента:
// self.clients.matchAll() → client.lifecycleState === 'frozen' | 'active'

PageLifecycle.js: кросс-браузерная абстракция

Официальная библиотека Google для сглаживания различий между браузерами. Менее 1 КБ в gzip.

import lifecycle from 'page-lifecycle';

lifecycle.addEventListener('statechange', ({ oldState, newState }) => {
  console.log(`Lifecycle: ${oldState} → ${newState}`);
  
  const handlers = {
    hidden: () => saveState(),
    frozen: () => { closeConnections(); saveStateSynchronously(); },
    terminated: () => sendFinalAnalytics(),
    active: () => {
      if (oldState === 'frozen' || oldState === 'hidden') refreshData();
    },
  };
  
  handlers[newState]?.();
});

console.log(lifecycle.state); // текущее состояние

Отладка

chrome://discards

Встроенная страница Chrome — показывает состояние вкладок и позволяет вручную вызвать Freeze или Discard. Ключевые колонки:

  • Utility Rank — приоритет вкладки. Первой выгружается та, что в самом низу.

  • Reactivation Score — вероятность возврата пользователя (0–1). Чем ниже, тем выше шанс быть выгруженной.

Также можно снять флаг Auto Discardable, чтобы Chrome не трогал конкретную вкладку.

Важно: закреплённая (pinned) вкладка от заморозки не защищена. Защищены вкладки с активной видеоконференцией, WebRTC, Web USB/Bluetooth/HID/Serial, Web Lock или блокирующей IndexedDB-транзакцией.

DevTools → Application → Back/forward cache

Показывает совместимость страницы с BFCache и список причин несовместимости с классификацией: Actionable / Pending Support / Not Actionable. Нажмите Run Test — DevTools автоматически проведёт переход вперёд и назад.

Логирование с отметками времени

const log = (msg) => console.log([${new Date().toISOString()}] ${msg});

Включите Preserve log — иначе журнал очищается при навигации и восстановлении из BFCache. Для надёжной отправки используйте navigator.sendBeacon или fetch с keepalive: true.

Практические сценарии для тестирования

Сценарий

Как воспроизвести

Базовый фон

Переключиться на другую вкладку на 15 секунд.

Интенсивный троттлинг

Оставить вкладку в фоне на 5+ минут с цепочкой таймеров.

Заморозка

chrome://discards → «Freeze».

Выгрузка

chrome://discards → «Discard».

BFCache

Перейти на другую страницу → кнопка «Назад».

Системный сон

Закрыть крышку ноутбука на 5–10 минут.

Energy Saver freeze

Включить Energy Saver → тяжёлый фоновый таб → 5+ минут.

Агрессивная выгрузка на iOS

Открыть 10+ вкладок на iPhone и переключаться между ними.

Итоги

Шесть принципов для real-time приложений:

1. Не полагайтесь на таймеры в фоне. Функция setInterval(fn, 1000) в скрытой вкладке может выполняться раз в минуту — или не выполняться вовсе. Стройте логику с учётом ненадёжности фоновых таймеров.

2. visibilitychange — обязательный обработчик. Это единственное событие, которое работает во всех браузерах и покрывает все основные сценарии перехода в фон и обратно.

3. Проверяйте соединения и данные при возврате. Считайте время отсутствия, проверяйте ws.readyState, при необходимости запрашивайте у сервера пропущенные события.

4. Рассмотрите закрытие WebSocket в pagehide и переоткрытие в pageshow при event.persisted. Такой подход помогает сохранить совместимость с BFCache в Chrome и улучшает поведение при нажатии кнопки «Назад».

5. Забудьте про unload. Вместо него используйте pagehide + fetch({ keepalive: true }) или navigator.sendBeacon(). Событие unload ненадёжно на мобильных устройствах и блокирует BFCache.

6. Energy Saver — новая реальность (Chrome 133+). С февраля 2025 года Chrome замораживает CPU-интенсивные фоновые вкладки при активном режиме энергосбережения. Критичную фоновую логику переносите в Service Worker.

Если вы сталкивались с похожими проблемами со «спящими» вкладками, троттлингом и BFCache — приходите обсудить это на Frontend Mix. На живом примере разберём, как браузер усыпляет вкладки, почему из-за этого ломается real-time и какие приёмы дебага и resync помогают находить и чинить их в продакшене.

Митап бесплатный, проходит 28 мая в 19:00 (мск) в Санкт‑Петербурге и онлайн. Регистрация и подробности — на сайте Frontend Mix.

Дополнительные материалы