Привет! Меня зовут Костя, я разработчик интерфейсов в Ю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 не был постоянно перегружен. Общая закономерность была одна: проблема чаще возникала после того, как вкладка долго находилась в фоне.
Что происходило под капотом — и как мы это реконструировали
Картина оказалась такой:
-
Оператор переключается на другую вкладку.
-
Через некоторое время браузер замораживает фоновую вкладку для экономии ресурсов. WebSocket при этом остаётся «живым», и входящие события продолжают накапливаться в буфере.
-
Оператор возвращается к вкладке.
-
Браузер «размораживает» страницу, после чего накопившиеся события почти одновременно попадают в обработчики.
-
На страницу обрушивается лавина вызовов API, повторных рендеров React и всплывающих уведомлений.
-
На слабом железе интерфейс начинает ощутимо тормозить, а часть элементов перестаёт отвечать.
Все части системы по отдельности работали нормально. Проблема возникала в момент «пробуждения» вкладки: браузер разом отдавал накопленные события и получал всплеск нагрузки — и на слабых машинах этого хватало для заметного фриза.
Как мы это доказали
Гипотеза сложилась быстро: интерфейс тормозил именно при возврате.

Мы включили логирование событий жизненного цикла — 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
Обычные страницы хранятся до ~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, []) повторно не выполнится.
// ❌ Не сработает при восстановлении из BFCacheuseEffect(() => { fetchData();}, []);// ✅ Обрабатываем BFCache явноuseEffect(() => { 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 из-за открытых соединений.
// Практический шаблон, если вы хотите дружить с BFCachewindow.addEventListener('pagehide', () => { if (ws) { ws.close(1000, 'pagehide'); ws = null; }});// Переоткрываем только при возврате из BFCachewindow.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.jslet 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.
Дополнительные материалы
ссылка на оригинал статьи https://habr.com/ru/articles/1038288/