Debounce e lote de vários eventos de análise

A maioria dos dados de análise, telemetria e Core Web Vitals tem prioridade menor do que responder ao usuário. Portanto, é possível adiar o envio até que o usuário saia da página. A exceção é quando você precisa fornecer atualizações em tempo real. Nesse caso, a pontualidade é importante.

A melhor maneira de fornecer atualizações de análise em tempo real, minimizando os beacons, é usar fetchLater() com a opção de configuração activateAfter. Isso permite que você elimine o debounce e agrupe todos os eventos de análise que ocorrem em um determinado período em um único beacon, que é enviado de forma confiável mesmo que o usuário saia da página antes do tempo limite.

Como fazer a implementação

  1. Programar a solicitação: assim que houver dados de análise relevantes disponíveis, chame fetchLater() com o payload de dados e transmita um valor activateAfter. Isso coloca os dados em fila para serem enviados após esse período ou se o usuário sair da página antes.

  2. Agrupar vários eventos em lote: se um novo evento de análise ocorrer antes que o tempo limite de activateAfter expire, cancele a solicitação programada anteriormente e chame fetchLater() novamente (com o mesmo valor de activateAfter) com a fila de eventos completa em um único payload.

  3. Redefinir a fila de eventos quando o tempo limite expirar: se um novo evento de análise ocorrer depois que o beacon programado for enviado (ou seja, o valor activated do resultado fetchLater() for true), redefina a fila de eventos.

  4. Deixe o navegador cuidar do resto: se o usuário sair ou fechar a guia antes que o tempo limite de activateAfter expire, o navegador ainda vai enviar de forma confiável o payload da sua chamada fetchLater() mais recente.

Exemplo de código

Esse código rastreia todos os eventos load e click em uma página e agrupa todos os eventos que ocorrem em um tempo limite de 10 segundos.

// Replace with your analytics endpoint.
const ANALYTICS_ENDPOINT = '/path/to/analytics/endpoint';

// Replace with a time window of your choice. All analytics events that
// occur within this time window will be batched together.
const BATCH_WINDOW = 10 * 1000;

// The maximum number of events to batch. Pick a number that is unlikely
// to overflow the fetchLater() quota for the page.
const MAX_QUEUE_SIZE = 100;

const eventQueue = [];
let fetchLaterResult;
let fetchLaterController;

function trackEvent(eventData) {
  // If the previously queued beacon has already been sent, or if the
  // max queue size has been met, reset the queue.
  if (fetchLaterResult?.activated || eventQueue.length > MAX_QUEUE_SIZE) {
    fetchLaterController = null;
    fetchLaterResult = null;
    eventQueue.length = 0;
  }

  eventQueue.push(eventData);

  // Abort any pending beacons before creating a new one.
  if (fetchLaterController) {
    fetchLaterController.abort();
  }
  fetchLaterController = new AbortController();

  // Schedule a fetch for the events to be sent when the batch window expires.
  // IMPORTANT: wrap the call in a try/catch to handle quota errors.
  try {
    fetchLaterResult = fetchLater(ANALYTICS_ENDPOINT, {
      method: 'POST',
      headers: {'content-type': 'application/json'},
      body: JSON.stringify(eventQueue),
      signal: fetchLaterController.signal,
      activateAfter: BATCH_WINDOW,
    });
  } catch (error) {
    // Handle errors as needed.
  }
}

// Track page loads.
window.addEventListener('load', () => {
  trackEvent({type: 'page_load'});
});

// Track click events.
window.addEventListener('click', (event) => {
  trackEvent({type: 'click', target: serializeElement(event.target)});
});

Práticas recomendadas

  • USE fetchLater() com a opção activateAfter definida para agrupar vários eventos de análise que ocorrem próximos uns dos outros.
  • USE um AbortController para cancelar a busca pendente quando um novo evento de análise ocorrer.
  • VERIFIQUE se você não está agrupando muitos eventos em lote e excedendo a cota de fetchLater() (atualmente, cerca de 64 KB por origem).
  • FAÇA o agrupamento de chamadas para fetchLater() em um try/catch para lidar com erros de cota.
  • FAÇA a detecção de recursos da presença de fetchLater() em globalThis e implemente uma estratégia de substituição para navegadores que não oferecem suporte à API.
  • NÃO use um objeto ReadableStream para o corpo da solicitação, porque isso vai causar um erro.

Suporte ao navegador e estratégias de substituição

Portanto, uma estratégia de fallback geralmente é necessária.

No entanto, considerando os benefícios de confiabilidade e desempenho dessa API, use fetchLater() se o navegador for compatível.

Polyfill fetchLater()

Use o seguinte polyfill mínimo de fetchLater(), que implementa a API da forma mais próxima possível em navegadores sem suporte.

A única diferença de comportamento notável com esse polyfill é que, em vez de enviar o payload quando o usuário sai da página, ele o envia sempre que o visibilityState da página muda para "oculto", já que esse é o sinal de fim de sessão mais confiável e amplamente disponível atualmente.

globalThis.fetchLater ??= function fetchLater(url, init = {}) {
  let timeoutHandle;
  let activated = false;

  function sendNow() {
    if (!(init.signal && init.signal.aborted)) {
      // Use fetch keepalive if the browser supports it or if custom fetch
      // parameters are specified (e.g. custom headers or methods).
      // Otherwise fall back to `navigator.sendBeacon()`.
      if (
        'keepalive' in Request.prototype ||
        init.method !== 'POST' ||
        init.headers
      ) {
        fetch(url, Object.assign({}, init, {keepalive: true}));
        activated = true;
      } else {
        activated = navigator.sendBeacon(url, init.body);
      }
    }
    destroy();
  }

  function destroy() {
    document.removeEventListener('visibilitychange', sendNow);
    clearTimeout(timeoutHandle);
  }

  if (document.visibilityState === 'hidden') {
    // If the beacon was created while the page is already hidden, send data
    // ASAP but wait until the next microtask to allow all sync code to run.
    queueMicrotask(sendNow);
  } else {
    document.addEventListener('visibilitychange', sendNow);

    if (typeof init.activateAfter === 'number' && init.activateAfter >= 0) {
      timeoutHandle = setTimeout(sendNow, init.activateAfter);
    }
  }

  if (init.signal) {
    init.signal.addEventListener('abort', destroy);
  }

  return {
    get activated() {
      return activated;
    },
  };
};