BroadcastChannel APIは、同じオリジンで開いているタブ・ウィンドウ・iframe・Workerへ、サーバーを経由せずにメッセージを一斉配信するブラウザ標準のAPIです。new BroadcastChannel('名前') でチャンネルに参加し、postMessage() で送り、message イベントで受け取ります。あるタブでログアウトしたら残りのタブも即座にログイン画面へ戻す、といった同期がライブラリなしで書けます。この記事では、HTML Living Standard(9.5節)の配信アルゴリズムとMDNの記述を軸に、書き方、届く範囲、ブラウザ対応、Reactでの実装、そしてbfcache(戻る・進むキャッシュ)との関係までを、Chrome 154とNode.js v26.5.0で動かした結果を添えて整理します。
まとめ:BroadcastChannel APIの要点
- 同じチャンネル名の
BroadcastChannelオブジェクト全部に届く。除外されるのは送信したオブジェクト自身だけで、同じタブ内の別インスタンスには届く。 - 届く範囲は「同一オリジン」かつ「同じストレージパーティション」。Chrome 115以降は、他サイトに埋め込まれたiframeとそのサイトを直接開いたタブの間では届かない。
- Chrome 54・Firefox 38・Safari 15.4・Edge 79以降が対応し、web-featuresのBaselineは2024年9月14日から「広く利用可能」。iOS 15.3以前を切れない案件でなければフォールバックは不要。
- メッセージは保存されない。その瞬間に受信可能な相手にしか届かず、bfcacheに入っているページへ送るとChrome 154ではそのページがキャッシュから破棄される。
- 使い終わったら
close()。リスナー付きのまま捨てたオブジェクトはページが閉じるまで解放されない、と仕様自身が警告している。
BroadcastChannelの配信ルールと届く範囲
仕様のIDLはごく小さく、プロパティは name、メソッドは postMessage(message) と close()、イベントは message と messageerror の2つだけです。closed のような状態確認用プロパティはありません。実装で迷うのは、どのオブジェクトにメッセージが届くかという配信ルールのほうです。
送信元オブジェクトだけを除く配信範囲
HTML仕様の postMessage() の手順は、同じチャンネル名で受信可能なオブジェクトを集めたうえで「Remove source from destinations」、つまり送信元オブジェクトだけを宛先から外します。「自分のタブには届かない」という説明を見かけますが、これは正確ではありません。1つのタブで同じ名前のチャンネルを2つ作り、片方から送ると、もう片方は受信します。Chrome 154で確かめると、同じタブの2つ目のインスタンスは自タブ発のメッセージを受け取り、送信した側だけが受け取りませんでした。
ここから導かれる設計は2つです。自タブの画面は送信と同時に自分で更新すること。そして、1つのタブの中でコンポーネントごとにチャンネルを作ると、自分が出したメッセージが別コンポーネント経由で戻ってくるので、メッセージに送信元IDを入れて弾くか、チャンネルをタブ内で1つに集約することです。
配送順について仕様が保証するのは、同じエージェント(同じタブや、そこから開いた同一サイトのウィンドウ、同じWorker)内の宛先が作成順に並ぶことだけです。別のタブ同士の順序は実装次第なので、「タブAが先に受け取る」前提のロジックは書かないでください。
同一オリジンとストレージパーティションの境界
宛先の条件は、チャンネル名が同じで、ストレージキーが一致することです。仕様上のストレージキーは現状オリジンだけですが、Chromeなどの実装ではトップレベルサイトも加えて分割しています。プロトコル・ホスト・ポートのどれかが違えば別オリジンなので、app.example.com と www.example.com の間でも届きません。サブドメインをまたいで通知したいなら、iframeを介して window.postMessage() で橋渡しする構成になります。
見落としやすいのが、サードパーティとして埋め込まれたiframeです。GoogleのStorage Partitioningの解説によれば、Chrome 115以降は全ユーザーでストレージと通信APIがトップレベルサイト単位に分けられ、BroadcastChannelもその対象です。同じ解説は、FirefoxやSafariにも同様の分割の取り組みがあるとしています。Chrome 154で、127.0.0.1 のページに埋め込んだ localhost のiframeと、localhost を直接開いたタブの間で送ってみると、iframeには届きませんでした。同じiframeを localhost のページに埋め込むと届きます。埋め込みウィジェットと本体サイトをBroadcastChannelでつなぐ設計は成立しない、と考えてください。iframeまわりのCookie分割はiframeのsandbox属性とCookie分割の解説で扱っています。
基本の書き方:生成・送信・受信・close
送信側も受信側も、同じ名前でチャンネルを作るところから始めます。ハンドシェイクも接続待ちもありません。
// 送る側・受け取る側とも同じ名前で作る
const channel = new BroadcastChannel('app');
channel.onmessage = (event) => {
console.log('受信:', event.data, event.origin);
};
channel.onmessageerror = (event) => {
console.warn('受信側で復元できないメッセージ', event);
};
channel.postMessage({ type: 'theme', value: 'dark' });
// 画面やWorkerを閉じる前に解放する
channel.close();
onmessage への代入はハンドラーを1つに差し替え、addEventListener('message', ...) は複数登録できます。コンポーネント単位で後から外したい場合は後者を使い、removeEventListener と組にしてください。
送れるデータと例外の種類
メッセージは構造化複製アルゴリズム(structured clone)で複製されます。オブジェクトや配列に加えて Date・Map・Set・Blob もそのまま届き、Node.js v26.5.0で送った Date と Map は受信側でも instanceof が真でした。一方で、関数や Symbol を含む値を渡すと、送信時点で DataCloneError が投げられます。クラスのインスタンスは中身のプロパティだけが届き、メソッドは失われます。
| 状況 | 起きること | 発生する側 |
|---|---|---|
| close()後にpostMessage() | InvalidStateError | 送信側(例外) |
| 関数・Symbolを含む値 | DataCloneError | 送信側(例外) |
| 受信側で復元できない値(例:別agent clusterへのSharedArrayBuffer) | messageerrorイベント | 受信側 |
| 受信側がclose()済み・bfcache中 | 届かない(bfcache中のページはChromeでは破棄) | なし |
postMessage() は引数を1つしか取らず、Worker.postMessage() のような転送リスト(transfer)を渡せません。大きな ArrayBuffer を送ると受信側ごとに復元され、宛先の数だけメモリ上にコピーができるので、数MB単位のデータはIndexedDBなどの保存先に置き、BroadcastChannelでは「更新された」という通知とキーだけを流す分担が向いています。
ブラウザ対応状況とNode.js・Workerでの利用
MDNのbrowser-compat-data(2026年9月24日版)とweb-featuresの記録では、主要エンジンの対応は次のとおりです。最後に対応したのはSafari 15.4(2022年3月14日)で、そこから30か月を経た2024年9月14日にBaselineの「広く利用可能」へ移りました。かつてのlocalStorageイベントによるフォールバックは、iOS 15.3以前を切れない案件でなければ書く必要はありません。
| 環境 | BroadcastChannel | messageerror |
|---|---|---|
| Chrome / Chrome for Android | 54以降 | 60以降 |
| Edge | 79以降 | 79以降 |
| Firefox | 38以降 | 57以降 |
| Safari / iOS Safari | 15.4以降 | 15.4以降 |
| Node.js | 15.4.0で追加・18.0.0でグローバル化 | 対応 |
仕様のIDLは [Exposed=(Window,Worker)] なので、Web Workerの中でも同じ名前のチャンネルを作ればタブと直接やり取りできます。Node.jsでは worker_threads のスレッド間通信として使え、18.0.0で実験的扱いが外れました。
Service Workerからタブへの通知と起動条件
Service Workerの中でもBroadcastChannelは使えますが、受信側としては当てになりません。Service Workerはアイドルになると停止し、BroadcastChannelのメッセージでは起動しないからです。これを起動条件にする提案はW3CのServiceWorkerリポジトリ(issue #975)で議論されましたが、2017年11月に「関心が集まらない」として閉じられました。Service Workerからタブへ確実に届けるなら clients.matchAll() で取得したクライアントに client.postMessage() で送り、タブからService Workerへは navigator.serviceWorker.controller.postMessage() を使います。Service Workerの役割全体はPWAとService Workerの実装手順を参照してください。
ログアウト同期の実装とメッセージ設計
HTML仕様が例に挙げている用途が、まさに「別タブでログアウトしたことを知らせる」ケースです。実務では、メッセージに type を持たせて分岐し、形が想定と違うものは無視する形にしておくと、後から種類を足しても壊れません。
const auth = new BroadcastChannel('myapp:auth:v1');
auth.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || typeof msg !== 'object') return;
switch (msg.type) {
case 'logout':
location.assign('/login');
break;
case 'login':
if (msg.userId !== currentUserId) location.reload(); // currentUserIdはアプリ側で保持している値
break;
}
});
async function logout() {
await fetch('/api/logout', { method: 'POST' });
auth.postMessage({ type: 'logout', at: Date.now() });
location.assign('/login'); // 送信したauth自身には届かないので自分で遷移する
}
チャンネル名に myapp: のような接頭辞と :v1 のような版を付けておくと、同じオリジンで動く別アプリとの衝突を避けられ、メッセージ形式を変えるときも旧版のタブと混線しません。
同一オリジンで動くスクリプトなら誰でも同じ名前のチャンネルに参加できます。アクセストークンや個人情報は流さず、「ログアウトした」「カートが更新された」という事実だけを送り、中身は受信側がサーバーや保存先から読み直してください。また、トークン更新のように1つのタブだけが実行すべき処理は、全タブに届く仕組みでは排他制御になりません。こちらはWeb Locks APIによる複数タブの排他制御と組み合わせます。
Reactで使うuseBroadcastChannelフック
Reactでは、チャンネルの生成と close() を useEffect の中で対にします。受信ハンドラーは useRef に入れておくと、ハンドラーが変わるたびにチャンネルを作り直さずに済みます。
import { useCallback, useEffect, useRef, useState } from 'react';
export function useBroadcastChannel(name, onMessage) {
const channelRef = useRef(null);
const handlerRef = useRef(onMessage);
useEffect(() => {
handlerRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
const channel = new BroadcastChannel(name);
channelRef.current = channel;
channel.onmessage = (event) => handlerRef.current?.(event.data);
return () => {
channel.close();
channelRef.current = null;
};
}, [name]);
return useCallback((data) => channelRef.current?.postMessage(data), []);
}
// 使い方:カートの件数を全タブでそろえる
function CartBadge() {
const [count, setCount] = useState(0);
const post = useBroadcastChannel('myapp:cart:v1', (msg) => {
if (msg?.type === 'cart:updated') setCount(msg.count);
});
const add = () => {
const next = count + 1;
setCount(next); // 自分の画面は自分で更新する
post({ type: 'cart:updated', count: next });
};
return <button onClick={add}>カート {count}</button>;
}
React 19.3.0とjsdom(BroadcastChannelはNode.js v26.5.0の実装)で2つのルートを StrictMode 付きで描画し、一方で追加するともう一方の件数も同じ値になること、アンマウントまで例外が出ないことを確かめています。StrictMode の開発時はエフェクトが「実行→クリーンアップ→再実行」されるため、クリーンアップで close() していないと、閉じていないチャンネルが残って同じメッセージを二重に受け取ります。フックの設計の考え方はReactカスタムフックのベストプラクティスにまとめています。
bfcacheとの関係:キャッシュ中のメッセージによるページ破棄
上位の国内解説5本はいずれもこの論点を扱っていません。HTML仕様は、メッセージの宛先を「eligible for messaging」なオブジェクト、つまり文書が完全にアクティブなウィンドウか停止していないWorkerに限っています。戻る・進むキャッシュ(bfcache)に入ったページは完全にアクティブではないので、宛先から外れ、その間のメッセージは受け取れません。
Chrome 154(ヘッドレス)で、リスナーを登録したページAから別ページへ移動し、別タブから同じチャンネルへ1通送ってから戻る、という手順を各2回試した結果が次の表です。
| ページAの状態 | bfcache中の送信 | 戻ったとき |
|---|---|---|
| リスナーあり | なし | bfcacheから復元 |
| リスナーあり | 1通 | 再読み込み(メッセージは消失) |
| pagehideでclose()済み | 1通 | bfcacheから復元 |
| BroadcastChannelなし | 1通 | bfcacheから復元 |
リスナーを持っているだけではbfcacheは妨げられないことを、Chrome 154で確かめました。問題はbfcache中にメッセージが届いたときで、ページはキャッシュから捨てられ、戻ると再読み込みになります。通知の多いアプリほど、「戻る」がキャッシュからの即時表示でなく再読み込みになりやすくなります。
対処は、pagehide でチャンネルを閉じ、pageshow の persisted が真のときに開き直す形です。閉じていた間の通知は届かないので、復元時に最新の状態を保存先から読み直します。
// showLoggedOutとsyncStateFromStorageはアプリ側で用意する関数
let channel;
function openChannel() {
channel = new BroadcastChannel('myapp:auth:v1');
channel.onmessage = (event) => {
if (event.data?.type === 'logout') showLoggedOut();
};
}
openChannel();
window.addEventListener('pagehide', () => channel.close());
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return; // 通常の読み込みは上で開いている
openChannel();
syncStateFromStorage(); // 隠れていた間の変化は届いていない
});
BroadcastChannelは「同じブラウザの、同じオリジンの、いま開いている相手への通知」に特化しています。この3つのどれかが外れるなら、別の手段を選んでください。
| 手段 | 届く範囲 | 保存 | 向く用途 |
|---|---|---|---|
| BroadcastChannel | 同一オリジン・同一パーティション(送信元除く) | なし | タブ間の通知 |
| storageイベント | 同一オリジンの他タブ | あり(文字列) | 保存と通知を兼ねる |
| window.postMessage | 参照を持つ相手1つ | なし | 別オリジンのiframe連携 |
| SharedWorker | 接続した同一オリジンのタブ | Worker内で保持 | 接続や状態の共有 |
| WebSocket / SSE | サーバー経由で他端末 | サーバー側 | 他ユーザー・他端末 |
storage イベントは値の保存に付随して発火する仕組みで、書き込んだタブ自身では発火せず、値も文字列に限られます。通知だけならBroadcastChannelのほうが、保存先への書き込みが不要で構造化データも送れ、送信元以外なら同じタブ内のインスタンスにも届きます。後から開いたタブにも状態を渡したいなら、保存はlocalStorageやIndexedDBが担い、通知だけをBroadcastChannelで流します。
HTML仕様自身が、共有状態のロック管理や1本のWebSocket接続を複数タブで共有するような込み入った用途にはSharedWorkerが最適だと書いています。SharedWorkerは長らくChrome for Androidで使えないことが採用の壁でしたが、browser-compat-dataではChrome for Android 148で対応し、web-featuresのBaselineは2026年5月5日に「新たに利用可能」になりました。ただし「広く利用可能」になるのは30か月後で、古いAndroid端末を切れない案件ではまだBroadcastChannelとWeb Locksの組み合わせが安全です。他ユーザーや他端末へ届けたいなら、そもそもブラウザ内の仕組みでは足りず、SSEやWebSocketが必要です。サーバーからWebSocketで受けた更新を、BroadcastChannelで他タブへ配り直す構成なら、Web Locksで接続を持つタブを1つに決めることで接続を1本に抑えられます。
よくある質問
BroadcastChannelはSafariで使えますか?
使えます。Safari・iOS Safariとも15.4(2022年3月14日)で対応し、Chrome・Firefox・Edgeと合わせて主要ブラウザがそろっています。Baselineは2024年9月14日から「広く利用可能」です。
postMessageしたのに自分のタブで受信できないのはなぜですか?
仕様どおりの動きで、送信したオブジェクト自身には届きません。同じタブでも別に作ったインスタンスには届くので、自タブの画面は送信した処理の中で直接更新してください。
サブドメインや別ドメインのタブと通信できますか?
できません。宛先はオリジンが一致する相手に限られ、app.example.com と www.example.com も別扱いです。Chrome 115以降は、他サイトに埋め込まれた同一オリジンのiframeとも分離されます。別オリジンとの連携は window.postMessage() を使います。
Service WorkerからBroadcastChannelでタブへ通知できますか?
Service Workerが起動している間なら送れますが、BroadcastChannelのメッセージでService Workerは起動しません。タブへ確実に届けるなら clients.matchAll() と client.postMessage() を使ってください。
閉じていたタブや後から開いたタブにもメッセージは届きますか?
届きません。BroadcastChannelはメッセージを保存しないため、送信の瞬間に開いていて受信可能だった相手にしか配られません。後から開くタブにも状態を渡すには、localStorageやIndexedDBに保存し、開いたときに読み込みます。