Voltar ao blog

A Transição de Estado de Visibilidade: Medindo Visualizações de Página Sem Beacons Confiáveis

pagehide, visibilitychange e a Beacon API se comportam de formas diferentes em cada navegador. Veja como registrar o fim de uma sessão de forma confiável, sem cookies nem identificadores persistentes.

Registrar que uma página foi vista é fácil. Registrar quando o usuário realmente saiu — de forma confiável, entre trocas de aba, envio para segundo plano e fechamentos abruptos — é um dos problemas mais espinhosos de web analytics. Isso importa porque métricas de engajamento (tempo de permanência, rejeição, profundidade de rolagem) dependem de capturar um sinal limpo de fim de sessão. Se você erra, ou perde dados ou os infla.

O Monoid resolve isso sem cookies, localStorage ou qualquer identificador persistente. Essa restrição na verdade simplifica as coisas: nunca precisamos reconciliar uma sessão entre carregamentos de página, então tudo o que importa é emitir uma única amostra final precisa por ciclo de vida da página. Veja como as primitivas do navegador se comportam, onde estão as armadilhas, e qual delas o tracker do próprio Monoid realmente usa.

Por que unload é o evento errado

A abordagem histórica era escutar unload ou beforeunload e disparar um XHR síncrono. Hoje isso é ativamente prejudicial. O evento unload não é confiável no mobile: quando o usuário manda a aba para segundo plano e o sistema operacional a recupera, unload frequentemente nunca dispara. Pior, registrar um handler de unload desqualifica a página do cache de avanço/retrocesso (bfcache) em vários navegadores, prejudicando o desempenho de navegação.

A orientação sobre a Page Lifecycle API no web.dev é explícita: trate a transição de visibilitychange para hidden como o último evento confiável que você vai observar. Não conte com unload ou pagehide disparando no mobile.

A dupla visibilitychange + pagehide

O padrão robusto é escutar visibilitychange e verificar document.visibilityState === 'hidden', com pagehide como sinal secundário. Sempre que a página transiciona para oculta, você descarrega o que precisa enviar. A documentação da Page Visibility API no MDN confirma que visibilitychange dispara quando uma aba vai para segundo plano ou o navegador é minimizado — a aproximação mais próxima de "o usuário parou de olhar".

A complicação: visibilitychange para oculta pode disparar várias vezes em uma sessão (sair da aba, voltar, sair de novo). Então você precisa tornar o envio idempotente, aplicar debounce, ou reiniciar seu próprio relógio para que cada disparo relate um segmento novo e sem sobreposição, em vez de um total crescente. O Monoid segue essa última abordagem: o cronômetro por trás da amostra de tempo de permanência é reiniciado a cada transição de visibilitychange, então um segundo evento hidden mais tarde no mesmo ciclo de vida da página relata apenas o tempo desde o último disparo. O Monoid escuta visibilitychange (ao lado de um fallback de beforeunload já existente), mas não adiciona um listener separado de pagehide — os dois se sobrepõem o suficiente nos casos que importam para que um segundo listener só reintroduzisse o problema de contagem duplicada, sem cobrir um território significativamente maior.

Enviando dados de uma página moribunda: navigator.sendBeacon

Você não pode disparar um fetch assíncrono durante o visibilitychange e esperar que ele conclua — o navegador pode destruir a página antes. É exatamente para isso que existe a especificação da Beacon API. navigator.sendBeacon(url, data) enfileira um pequeno POST que o navegador garante tentar mesmo depois que o documento se foi, sem bloquear a thread principal nem atrasar a navegação.

O Beacon tem limites que vale respeitar:

  • Tamanho do payload. A especificação permite que o user agent rejeite beacons acima de um limite definido pela implementação (comumente 64 KB). Mantenha payloads minúsculos — os do Monoid têm algumas centenas de bytes.
  • Método. Beacons são sempre POST. Se seu endpoint na edge espera GET, adapte-o.
  • Sem resposta. Você não consegue ler nada de volta. Dispare e esqueça.

Uma alternativa moderna é fetch(url, { keepalive: true }), que o padrão Fetch define como permitindo que uma requisição sobreviva ao documento. keepalive te dá cabeçalhos e métodos que o Beacon não tem, mas compartilha o mesmo teto de tamanho. A sabedoria convencional diz para usar Beacon primeiro e cair para fetch com keepalive — o Monoid na verdade inverte isso, pelos motivos que a próxima seção explica.

Uma implementação mínima e sem cookies

Aqui está um padrão de referência combinando tudo acima — sendBeacon primeiro, fetch keepalive como seu fallback, disparado tanto por visibilitychange quanto por pagehide, com uma proteção para que um disparo repetido não envie duas vezes:

let sent = false;
function flush() {
  if (sent) return;
  sent = true;
  const payload = JSON.stringify({
    path: location.pathname,
    // sem identificadores, sem cookies, sem fingerprint
    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);

Note o que está ausente: nenhum listener de unload, então a elegibilidade ao bfcache é preservada. Nenhum ID armazenado entre carregamentos. O valor de visibleMs usa performance.now(), um relógio monotônico que não precisa de timestamp de relógio de parede nem de estado persistente.

O tracker do próprio Monoid segue um caminho relacionado, mas distinto: apenas fetch keepalive (sem sendBeacon — ele não consegue expressar o mode: 'cors' de que uma instalação entre origens diferentes precisa), sem proteção sent, e um cronômetro que reinicia a cada transição de visibilidade em vez de disparar uma vez e silenciar (a checagem de consentimento e Do Not Track foi omitida abaixo por clareza):

function dur() {
  fetch('/duration', {
    method: 'POST',
    body: JSON.stringify({ site_id: siteId, duration_ms: Date.now() - t }),
    headers: { 'Content-Type': 'application/json' },
    keepalive: true,
    mode: 'cors',
  });
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') dur();
  t = Date.now();
});
window.addEventListener('beforeunload', dur);

Reiniciar t a cada transição faz o mesmo trabalho que a proteção sent faz acima, sem precisar de uma flag: cada chamada só relata o segmento desde a última reinicialização do relógio, então um disparo de visibilitychange seguido por um beforeunload no mesmo fechamento de aba relata um segmento real e outro quase-zero inofensivo, em vez de contar tudo em dobro.

Por que isso combina com analytics privacy-first

Como o Monoid nunca costura sessões, cada requisição é uma observação completa e descartável. Não há identificador entre páginas a proteger, então uma amostra perdida ou duplicada custa a você um ponto de dado, não um perfil de usuário corrompido. O padrão visibilitychange mais beforeunload que o Monoid realmente distribui oferece medição precisa de permanência mantendo a página rápida e amigável ao bfcache — o que por si só melhora os Core Web Vitals que você talvez esteja tentando medir.

Meça a saída, não a pessoa. O navegador já te dá tudo de que você precisa.

Sources

Comments

Loading comments…