惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

有赞技术团队
有赞技术团队
量子位
B
Blog RSS Feed
Schneier on Security
Schneier on Security
L
LINUX DO - 最新话题
博客园 - 三生石上(FineUI控件)
Recent Announcements
Recent Announcements
Hacker News: Ask HN
Hacker News: Ask HN
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Google DeepMind News
Google DeepMind News
N
News | PayPal Newsroom
阮一峰的网络日志
阮一峰的网络日志
Microsoft Security Blog
Microsoft Security Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
T
Tailwind CSS Blog
MongoDB | Blog
MongoDB | Blog
大猫的无限游戏
大猫的无限游戏
PCI Perspectives
PCI Perspectives
aimingoo的专栏
aimingoo的专栏
D
Docker
T
The Exploit Database - CXSecurity.com
Last Week in AI
Last Week in AI
W
WeLiveSecurity
Stack Overflow Blog
Stack Overflow Blog
月光博客
月光博客
Vercel News
Vercel News
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
J
Java Code Geeks
O
OpenAI News
C
Cisco Blogs
Hacker News - Newest:
Hacker News - Newest: "LLM"
爱范儿
爱范儿
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Threat Research - Cisco Blogs
Cisco Talos Blog
Cisco Talos Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
Help Net Security
Help Net Security
Scott Helme
Scott Helme
The Hacker News
The Hacker News
Y
Y Combinator Blog
A
Arctic Wolf
V
V2EX
P
Proofpoint News Feed
Simon Willison's Weblog
Simon Willison's Weblog
A
About on SuperTechFans
S
Securelist
G
Google Developers Blog
Cyberwarzone
Cyberwarzone
The GitHub Blog
The GitHub Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как «спят» вкладки в браузере
KOSTDM (ЮMon · 2026-05-22 · via Все публикации подряд на Хабре

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

Уровень сложностиСредний

Время на прочтение16 мин

Охват и читатели70

Привет! Меня зовут Костя, я разработчик интерфейсов в Ю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 явно
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 из-за открытых соединений.

// Практический шаблон, если вы хотите дружить с 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.

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