ブログに戻る

navigator.sendBeacon と fetch keepalive:ナビゲーションを止めずに確実にアナリティクスを送信する

ナビゲーションを止めずにページのアンロードを乗り越えてアナリティクスのペイロードを送る方法。sendBeacon と fetch keepalive の選択が、Cookie を使わない計測にとってなぜ重要かを解説します。

ページビューやイベントを記録すること自体は、ページが生きている間は簡単です。難しいのは、ユーザーが別のページへ移動する瞬間、タブを閉じる瞬間、あるいはモバイルでアプリをバックグラウンドに送る瞬間に、その最後のペイロードを届けることです。ここを誤ると、データを失うか、もっと悪いことに、ユーザーが求めたまさにそのナビゲーションを遅らせてしまいます。この記事では、信頼できる二つの選択肢——navigator.sendBeacon()keepalive: true を指定した fetch()——を比較し、Monoid のトラッカーが実際にどちらを使っているか、そしてその理由を、Cookie もストレージもフィンガープリントも使わずに説明します。

なぜアンロードは特殊なケースなのか

ドキュメントがアンロードを始めるとき、ブラウザーはすでに終了処理に入っています。同期的な XMLHttpRequest は、リクエストが完了するまでナビゲーションをブロックする目的で歴史的に濫用されてきましたが、これは応答性を損ないます。現代のブラウザーはこれを積極的に抑制しており、unload の最中に開始されたリクエストは頻繁にキャンセルされます。Beacon の仕様はまさにこの問題を解決するために存在します。ページがリクエストをスケジュールでき、ユーザーエージェントはそれを非同期に、次のページの読み込みをブロックすることなく試行することを保証します。

アナリティクスにとっての実務的な含意はこうです。beforeunload/unload ハンドラーから発火した通常の fetchXHR を当てにしてはいけません。それは端末を一度も離れないまま終わる可能性があります。

navigator.sendBeacon

sendBeacon はまさにこの目的のために作られています。小さな POST をキューに入れ、真偽値を即座に返しますが、それはリクエストのキューイングに成功したかどうかを示すだけで、送信自体が成功したかどうかではありません。その後、ブラウザーはページが消えた後であってもバックグラウンドでそれを送信します。

const ok = navigator.sendBeacon('/collect', payload);

強みは次のとおりです。

  • 転送はドキュメントの生存期間に縛られません。
  • 優先度は低く、次のナビゲーションと競合しないよう設計されています。
  • レスポンス処理が存在しないため、待つべきものが何もありません。

仕様およびブラウザーの実装による制約は次のとおりです。

  • POST のみ。任意のメソッドは設定できません。
  • ヘッダーへの制御は限定的です。Content-Type はペイロードの種類から推測されます(たとえば Blob を使えば間接的に影響を与えられますが、カスタムのリクエストヘッダーを自由に設定することはできません)。
  • ペイロードはユーザーエージェントのデータ上限に対してカウントされ、大きすぎる beacon は false を返します。

Cookie を使わないアナリティクスでは、これでたいてい十分です——多くのトラッカーがまさにこれだけを実装し、それ以上は求めていません。Monoid がそうしていないのには明確な理由があります。sendBeaconmode: 'cors' を表現できず、Monoid のインストール用スニペットは、サイトがホストページとは異なるオリジンに data-api-url を向けることを許しているからです。この一つの制約が、以下の比較を決定づけます。

keepalive を使った fetch

Fetch 標準は keepalive フラグを定義しており、これによりリクエストを発行元のページよりも長く生存させることができ、より豊富な API を保ちながら sendBeacon に近い耐久性を得られます。

fetch('/collect', {
  method: 'POST',
  keepalive: true,
  headers: { 'Content-Type': 'application/json' },
  body: payload,
});

強みは次のとおりです。

  • メソッド、ヘッダー、ボディを完全に制御できます。
  • 検査可能な実際の Response を得られます(ただし、アンロード中はそれを待つべきではないことが多いです)。

制約は次のとおりです。

  • Fetch 仕様は、進行中のすべての keepalive リクエストのボディの合計サイズを、ドキュメントごとに 64 KiB に制限しています。これを超えると fetch は拒否されます。これはドキュメント単位の予算であり、複数の keepalive リクエストが同時に実行されればそれを共有します。
  • 歴史的に keepalive のサポートは sendBeacon に遅れて追随してきており、実装上の癖もありました。想定するユーザーが実際に使っているブラウザーでテストしてください。

これこそが、Monoid のトラッカーがすべてのリクエスト——/collect/duration/event のいずれでも——で使っている方式です。

fetch(endpoint, {
  method: 'POST',
  body: JSON.stringify(payload),
  headers: { 'Content-Type': 'application/json' },
  keepalive: true,
  mode: 'cors',
});

前のセクションで決定要因となったのは mode: 'cors' であって、ペイロードのサイズやヘッダーの数ではありません——ほとんどの解説記事がこの選択を純粋にサイズの問題として語っている以上、これは覚えておく価値があります。

どちらを選ぶべきか

実用的なルールはこうです。何も返してもらう必要のないファイア・アンド・フォーゲット型のテレメトリには sendBeacon を使い、カスタムヘッダー、より大きな構造化リクエスト、あるいはクロスオリジンの mode 制御が本当に必要な場合にだけ fetchkeepalive に手を伸ばしましょう。いずれにせよペイロードは小さく保つべきです——keepalive の 64 KiB という予算を大きく下回る程度に——なぜなら、アナリティクスのイベントは設計上、最小限であるべきだからです。データ最小化は単なるコンプライアンス上の姿勢ではなく、アンロード時の信頼できる配信を可能にするものそのものです。Monoid はこのルールがすでに織り込んでいる例外です。ペイロードは小さく、レスポンスは決して読まれません——まさに sendBeacon が想定していたプロファイルですが、mode: 'cors' のサポートがそれに勝るのです。

本当に耳を傾けるべきイベント

unload に頼ってはいけません。信頼できないうえに、戻る/進むキャッシュ(bfcache)を無効化してしまいます。Page Lifecycle のガイダンスは、visibilitychange を購読し、hidden への遷移を、データをフラッシュできる最後の信頼できる瞬間として扱うことを推奨しています。これはデスクトップでのクローズ、タブの切り替え、モバイルでのアプリのバックグラウンド化のいずれにおいても一貫して機能します——いずれも unload が決して発火しないケースです。

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

Monoid のトラッカーはこのパターンに従っています。visibilitychange のリスナーが beforeunload のフォールバックと並んで存在するため、タブがモバイルでバックグラウンドに送られても、デスクトップで完全に閉じられても、滞在時間のサンプルが報告されます。

これが Cookie を使わないモデルにどう合致するか

Monoid は Cookie もストレージも一切読み取らず、フィンガープリントも行わないため、各リクエストはそもそもすでに明らかになっている情報——大まかなイベントと、ブラウザーがどのみち送信するヘッダー——しか運びません。永続させるべき識別子が存在しないので、状態を同期するための双方向のやり取りは一度も必要になりません。だからこそ、mode: 'cors'sendBeacon を選択肢から外した時点で、fetchkeepalive が自然な選択になるのです。レスポンスを一度も検査しない一撃限りの POST は、私たちにとって制約ではありません。むしろそれは、配信の信頼性とプライバシー・バイ・デザインを両立させるものなのです。

Sources

Comments

Loading comments…