Назад к блогу

Переход состояния видимости: измеряем просмотры страниц без надёжных beacon-запросов

pagehide, visibilitychange и Beacon API ведут себя по-разному в разных браузерах. Вот как надёжно зафиксировать конец сессии без cookie и постоянных идентификаторов.

Записать, что страницу просмотрели, легко. Записать, когда пользователь на самом деле ушёл, — надёжно, с учётом переключения вкладок, ухода в фон и жёстких закрытий — одна из самых каверзных задач веб-аналитики. Это важно, потому что метрики вовлечённости (время на странице, отказы, глубина прокрутки) зависят от чистого сигнала о завершении сессии. Ошибётесь — и вы либо теряете данные, либо раздуваете их.

Monoid решает это без cookie, localStorage и любых постоянных идентификаторов. Это ограничение на самом деле всё упрощает: нам никогда не нужно сшивать сессию между загрузками страниц, поэтому важно лишь одно — отправить один точный финальный beacon за жизненный цикл страницы. Вот как ведут себя браузерные примитивы и где расставлены ловушки.

Почему unload — неподходящее событие

Исторически подписывались на unload или beforeunload и отправляли синхронный XHR. Сегодня это откровенно вредно. Событие unload ненадёжно на мобильных устройствах: когда вкладку уводят в фон и ОС забирает её ресурсы, unload часто не срабатывает вовсе. Хуже того, регистрация обработчика unload лишает страницу права на кэш переходов назад/вперёд (bfcache) в нескольких браузерах, ухудшая производительность навигации.

Руководство по Page Lifecycle API на web.dev говорит прямо: считайте переход visibilitychange в hidden последним надёжным событием, которое вы увидите. Не рассчитывайте на срабатывание unload или pagehide на мобильных.

Связка visibilitychange + pagehide

Устойчивый приём — слушать visibilitychange и проверять document.visibilityState === 'hidden', а pagehide использовать как вспомогательный сигнал. Как только страница переходит в скрытое состояние, вы сбрасываете всё, что нужно отправить. Документация MDN по Page Visibility API подтверждает: visibilitychange срабатывает, когда вкладка уходит в фон или браузер сворачивают, — ближайшее приближение к «человек перестал смотреть».

Сложность в том, что переход в скрытое состояние может произойти несколько раз за сессию (ушли со вкладки, вернулись, ушли снова). Значит, сброс нужно сделать идемпотентным, либо применить debounce, либо отправлять только дельту с прошлого раза. Поскольку Monoid не хранит состояние по пользователю, мы отправляем единственную самодостаточную полезную нагрузку и позволяем edge дедуплицировать её в рамках запроса.

Отправка данных с умирающей страницы: navigator.sendBeacon

Нельзя запустить асинхронный fetch во время visibilitychange и ждать его завершения — браузер вправе снести страницу раньше. Именно для этого существует спецификация Beacon API. navigator.sendBeacon(url, data) ставит в очередь небольшой POST, попытку отправки которого браузер гарантирует даже после исчезновения документа, не блокируя основной поток и не задерживая навигацию.

У Beacon есть ограничения, которые стоит уважать:

  • Размер тела. Спецификация разрешает user agent отклонять beacon-запросы сверх лимита, определяемого реализацией (обычно 64 КБ). Держите тело крошечным — у Monoid это несколько сотен байт.
  • Метод. Beacon всегда POST. Если ваш edge-эндпоинт ждёт GET, адаптируйте его.
  • Нет ответа. Прочитать ничего обратно нельзя. Отправил и забыл.

Современный запасной вариант — fetch(url, { keepalive: true }): стандарт Fetch определяет его как разрешение запросу пережить документ. keepalive даёт заголовки и методы, которых у Beacon нет, но потолок размера у них общий. Сначала пробуйте Beacon, затем откатывайтесь на fetch с keepalive.

Минимальная реализация без cookie

let sent = false;
function flush() {
  if (sent) return;
  sent = true;
  const payload = JSON.stringify({
    path: location.pathname,
    // никаких идентификаторов, cookie и отпечатков
    visibleMs: Math.round(performance.now())
  });
  const ok = navigator.sendBeacon('/collect', payload);
  if (!ok) {
    fetch('/collect', { method: 'POST', body: payload, keepalive: true });
  }
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);

Обратите внимание на то, чего здесь нет: обработчика unload, поэтому право на bfcache сохраняется. Нет сохранённого между загрузками идентификатора. Значение visibleMs берётся из performance.now() — монотонных часов, которым не нужны ни отметка астрономического времени, ни постоянное состояние.

Почему это подходит аналитике privacy-first

Поскольку Monoid никогда не сшивает сессии, каждый beacon — цельное одноразовое наблюдение. Нет межстраничного идентификатора, который нужно защищать, поэтому потерянный beacon стоит вам одной точки данных, а не испорченного профиля пользователя. Связка visibilitychange плюс Beacon даёт точное измерение времени на странице и одновременно оставляет страницу быстрой и дружественной к bfcache — а это само по себе улучшает те самые Core Web Vitals, которые вы, возможно, пытаетесь измерить.

Измеряйте уход, а не человека. Браузер уже даёт вам всё необходимое.

Sources

Comments

Loading comments…