12k
All articles

BroadcastChannelによるブラウザタブの同期

BroadcastChannelでタブをリアルタイム同期。structured clone、クリーンアップ、フォールバック、認証・カート・テーマの実践パターンを解説。

OpenReplay Team
OpenReplay Team
BroadcastChannelによるブラウザタブの同期

BroadcastChannel APIは、ブラウザネイティブのメッセージバスです。同一オリジン上のタブ、ウィンドウ、iframe、ワーカー間でリアルタイムに通信できます。これは、マルチタブ状態のドリフト(あるタブでログアウトしても、他のタブにはログイン済みのUIが残り続ける問題)に対する、まさに目的に特化した解決策です。サーバー不要、ハンドシェイク不要、依存関係なしの4つのAPIコールだけで構成されています。本記事では、最小限のAPI、スケーラブルなアクション型メッセージパターン、適切なクリーンアップを伴うフレームワーク統合、見落とされがちな注意点、そして実行可能なフォールバックを備えた現在のブラウザサポート状況について解説します。

重要なポイント

  • APIは new BroadcastChannel(name)postMessage(data)onmessageclose() の4つのコールのみで構成されており、サーバー、ハンドシェイク、設定は一切不要です。
  • postMessage は構造化クローンアルゴリズムを使用するため、オブジェクト、Map、Set、Blobをそのまま送信できます。JSON.stringify も受信側での手動パースも不要です。
  • タブは自身のブロードキャストを受信しません。メッセージは送信したオブジェクト以外のすべてのリスニングチャンネルに送られるため、従来であれば自前で対策が必要だったフィードバックループを防止できます。
  • BroadcastChannelはバケツではなくパイプです。メッセージを転送するだけで何も保存しないため、イベント発火後に開かれたタブへの永続化と初期化のために、localStorageまたはIndexedDBと組み合わせて使用してください。
  • BroadcastChannelはBaselineとして広く利用可能で、2022年3月以降、Chrome、Firefox、Edge、Safari、Opera、Samsung Internetに対応しています。SafariはバージョN15.4でサポートが追加されており、Internet Explorerのみ非対応です。

マルチタブ状態のドリフトとは何か?

マルチタブ状態のドリフトとは、同じアプリの2つの開いているタブが異なる状態を保持するバグの一種です。一方のタブでログアウトしても、もう一方にはダッシュボードが表示されたまま、あるいは一方でカートを空にしても、もう一方にはまだ商品が表示されているといった状況が該当します。このようなフローのセッションリプレイには、典型的な症状が頻繁に現れます。別のタブで空にされたカートに対してユーザーが注文を送信したり、ログアウト済みであるべきタブで操作を続けたりといったケースです。BroadcastChannelはまさにこの非同期状態を解消します。

開発者はこれまで、3つの劣ったワークアラウンドで対処してきました。localStoragestorage イベントはタブ間で発火しますが、文字列のみしか扱えないため、すべてのメッセージでシリアライズ/パースが必要となり、キーの管理も煩雑になります。サーバーや localStorage をインターバルでポーリングする方法は無駄が多く遅延も生じます。ほとんどの場合、何も変化がないのにチェックのコストを払い続けることになります。SharedWorker やWebSocketは本格的なツールですが、同一マシン上の2つのタブを同期するためだけにサーバー経由のソケット通信を行うのは、純粋にローカルな問題に対して重すぎる解決策です。

方式ペイロード型永続化ネットワーク要否最適なユースケース
BroadcastChannel構造化クローン(オブジェクト、Map、Blob)なし不要ローカルな同一オリジンのタブ/ワーカー同期
storage イベント文字列のみあり(localStorage)不要組み込みの永続化を活かしたシンプルな同期
SharedWorker構造化クローンなし不要タブ間での共有計算/接続
WebSocket文字列/バイナリサーバー側必要クライアント間のサーバープッシュによるライブデータ

4つのコールで理解するBroadcastChannel API

APIは4つのコールのみで構成されており、同じ名前でチャンネルを作成したコンテキストはすべて同じバスに参加します。接続、リスン、送信、クローズの流れです。

const channel = new BroadcastChannel("app-sync");

channel.onmessage = (event) => {
  console.log("Received:", event.data);
};

channel.postMessage({ hello: "world" });

channel.close(); // アンマウント時 / 終了処理時

ペイロードがこのAPIの最大の利点です。postMessage で送信されるデータは構造化クローンアルゴリズムでシリアライズされるため、オブジェクト、配列、MapSetBlob を文字列化せずにそのまま渡せます。受信側は event.data をそのままオブジェクトとして読み取れます。SymbolとSharedArrayBufferはクローンできない代表的な例外です。チャンネルは同一オリジンのみで動作し、コンテキストごとに1つのチャンネルインスタンスを再利用することが重要です。送信のたびに新しい BroadcastChannel を生成するとリスナーがリークします。

BroadcastChannelのメッセージはどのように構造化すべきか?

スケーラブルなパターンは、{ type, payload } という識別子付きメッセージと、type でスイッチして各アクションをローカル状態に適用する単一のリスナーを組み合わせることです。これにより、すべてのコンテキストが同じプロトコルで通信できます。APIはメッセージに意味を持たせないため、プロトコルは自分で定義します。

const channel = new BroadcastChannel("cart-sync");
let cart = [];

channel.onmessage = ({ data }) => {
  if (data.type === "ADD_ITEM") cart.push(data.payload);
  else if (data.type === "CLEAR") cart = [];
  render();
};

function addItem(item) {
  cart.push(item);       // このタブの状態を即座に更新
  render();
  channel.postMessage({ type: "ADD_ITEM", payload: item });
}

送信側はブロードキャストに自身の状態を直接更新していることに注目してください。タブは自身のメッセージを受信しないため、ローカルの変更をインラインで適用し、ブロードキャストで他のタブに伝播させます。

タブ間での認証、カート、設定の同期

最も効果的なユースケースは、セッション同期(ログアウトのブロードキャストですべてのタブをクリア)、カート/設定/テーマの同期、マルチタブフォームの自動入力です。Reactでは、useEffect 内でチャンネルを作成し、close() を呼び出すクリーンアップ関数を返してください。これを省略すると、リマウントのたびにリスナーがリークします。

import { useEffect } from "react";

function useAuthSync(onLogout) {
  useEffect(() => {
    const channel = new BroadcastChannel("auth");
    channel.onmessage = ({ data }) => {
      if (data.type === "LOGOUT") onLogout();
    };
    return () => channel.close();
  }, [onLogout]);
}

// ログアウト時: ローカルセッションをクリアした後、
// new BroadcastChannel("auth").postMessage({ type: "LOGOUT" })

Svelte 5(runes)では、チャンネルをストアでラップします。注意点として、postMessage にはリアクティブプロキシではなくプレーンなクローンが必要です。ブロードキャスト前に $state.snapshot で値を取り出してください。プロキシのままでは構造化クローンのステップで失敗します。

// theme.svelte.js — Svelte 5 (runes)
export class ThemeStore {
  value = $state("light");
  #channel = new BroadcastChannel("theme");
  constructor() {
    this.#channel.onmessage = ({ data }) => { this.value = data; };
  }
  set(next) {
    this.value = next;
    this.#channel.postMessage($state.snapshot(next));
  }
}

多くのチュートリアルが見落とす注意点

実装でつまずく4つの挙動があります。多くの解説記事がここで説明を省いています。

  • タブは自身のブロードキャストを受信できません。 メッセージは送信したオブジェクトを除くすべてのリスニングチャンネルに送られます。これは仕様上の動作であり、有益な特性です。自己IDチェックで防御しなければならなかった無限フィードバックループを防止できます。送信側の変更はインラインで適用してください。
  • 「同一オリジン」は実際には「同一ストレージパーティション」を意味します。 ストレージはトップレベルサイトでパーティション化されるため、クロスサイトiframeは同一オリジンであってもトップレベルページとチャンネルを共有できません。また、異なるサブドメイン間でチャンネルがブリッジされることはありません。
  • パイプであり、バケツではありません。 BroadcastChannelはメッセージを転送するだけで何も保存しません。イベント発火後に開かれたタブを初期化するためにも、localStorageまたはIndexedDBと組み合わせて永続化してください。ブロードキャストを受信できなかった遅れて開いたタブは、マウント時に永続化された状態を読み込みます。
  • アンマウント時には必ず close() を呼び出してください。 クローズするとオブジェクトが切断され、ガベージコレクションの対象になります。コンポーネントごとにチャンネルを作成してクローズしなければ、リマウントのたびにリスナーがリークします。

ブラウザサポートと機能検出によるフォールバック

BroadcastChannelはBaselineとして2022年3月以降広く利用可能であり、現在のSafariは完全にサポートしています。「WebKitでは動作しない」という主張はSafari 15.4以前の話です。caniuseChrome for Developersブログによると、最低バージョンはChrome 54以上、Edge 79以上、Firefox 38以上、Safari 15.4以上(macOSおよびiOS)、Opera 41以上、Samsung Internet 7.2以上です。Internet Explorerのみが未対応です。

2022年以前のブラウザ向けには、'BroadcastChannel' in window で機能検出を行い、同じインターフェースを公開する storage イベントのシムにフォールバックしてください。

function createChannel(name) {
  if ("BroadcastChannel" in window) return new BroadcastChannel(name);
  // フォールバック: localStorage の "storage" イベント(文字列のみ)
  return {
    postMessage: (data) =>
      localStorage.setItem(name, JSON.stringify({ data, t: Date.now() })),
    set onmessage(fn) {
      window.addEventListener("storage", (e) => {
        if (e.key === name && e.newValue) fn({ data: JSON.parse(e.newValue).data });
      });
    },
    close() {},
  };
}

まずネイティブAPIを使用し、シムは保険として扱ってください。現在のすべてのブラウザでBroadcastChannelはすでに利用可能です。チャンネル名を1つ決め、{ type, payload } に標準化し、完全なクローズ後も残すべき状態を永続化すれば、アプリに潜んでいた古いタブのバグは解消されます。

よくある質問

BroadcastChannelは異なるサブドメイン間で動作しますか?

いいえ。BroadcastChannelは同一オリジンだけでなく、同一ストレージパーティションにスコープされており、異なるサブドメイン間でブリッジされることはありません。app.example.comのページとaccount.example.comのページはチャンネルを共有できません。同一オリジンであっても、ストレージがトップレベルサイトでパーティション化されるため、クロスサイトiframeはトップレベルページとチャンネルを共有できません。サブドメイン間の同期にはサーバーまたはWebSocketを使用してください。

クロスタブ同期においてBroadcastChannelとstorageイベントの違いは何ですか?

BroadcastChannelはオブジェクト、Map、Set、Blobなどの構造化クローンペイロードを直接転送しますが、何も保存しません。localStorageのstorageイベントは文字列のみを扱うため、すべてのメッセージでシリアライズとパースが必要ですが、副作用として永続的な状態を書き込みます。リッチなメッセージ駆動の同期にはBroadcastChannelを使用し、永続化が必要な場合やイベント発火後に開かれたタブを初期化する必要がある場合はlocalStorageまたはIndexedDBと組み合わせてください。

BroadcastChannelでメッセージを送信したタブは自身のメッセージを受信しますか?

いいえ。仕様上、メッセージは送信したオブジェクトを除くチャンネルのすべてのBroadcastChannelオブジェクトに送られます。タブは自身のブロードキャストを受信しないため、自己IDチェックで防御しなければならなかった無限フィードバックループを防止できます。このため、postMessageを呼び出す前に送信側の状態変更をインラインで適用し、ブロードキャストで他のすべてのコンテキストに伝播させてください。

すべてのタブが閉じられた場合、BroadcastChannelのメッセージはどうなりますか?

メッセージは失われます。BroadcastChannelはバケツではなくパイプです。メッセージを転送するだけで何も保存しないため、他のタブがリスニングしていない間にブロードキャストされた状態は、すべてのインスタンスが閉じられると消えてしまいます。イベント発火後に開かれたタブは元のメッセージを受信できません。これを防ぐには、状態をlocalStorageまたはIndexedDBに永続化し、チャンネルだけに頼らず、遅れて開いたタブがマウント時にそのストレージから初期化できるようにしてください。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.