BroadcastChannel APIは、同じオリジンで開いているタブ・ウィンドウ・iframe・Workerへ、サーバーを経由せずにメッセージを一斉配信するブラウザ標準のAPIです。new BroadcastChannel('名前') でチャンネルに参加し、postMessage() で送り、message イベントで受け取ります。あるタブでログアウトしたら残りのタブも即座にログイン画面へ戻す、といった同期がライブラリなしで書けます。この記事では、HTML Living Standard(9.5節)の配信アルゴリズムとMDNの記述を軸に、書き方、届く範囲、ブラウザ対応、React・Vueでの実装、Web Locksとの組み合わせ、bfcache(戻る・進むキャッシュ)との関係までを、Chrome 155と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日から「広く利用可能」。iPhoneのSafariも15.4以降で使える。
- メッセージは保存されない。その瞬間に受信可能な相手にしか届かず、bfcacheに入っているページへ送るとChrome 155ではそのページがキャッシュから破棄される。
- 全タブに届くだけで排他はしない。WebSocket接続のように1タブだけが持つべきものは、Web Locksでリーダーを決め、リーダーのタブが
pagehideでロックを手放す形にする。 - 使い終わったら
close()。リスナー付きのまま捨てたオブジェクトはページが閉じるまで解放されない、と仕様自身が警告している。
BroadcastChannelの配信ルールと届く範囲
仕様のIDLはごく小さく、プロパティは name、メソッドは postMessage(message) と close()、イベントは message と messageerror の2つだけです。closed のような状態確認用プロパティはありません。実装で迷うのは、どのオブジェクトにメッセージが届くかという配信ルールのほうです。
送信元オブジェクトだけを除く配信範囲
HTML仕様の postMessage() の手順は、同じチャンネル名で受信可能なオブジェクトを集めたうえで「Remove source from destinations」、つまり送信元オブジェクトだけを宛先から外します。「自分のタブには届かない」という説明を見かけますが、これは正確ではありません。1つのタブで同じ名前のチャンネルを2つ作り、片方から送ると、もう片方は受信します。Chrome 155で確かめると、同じタブの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 155で、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' });
// 利用を終えた時点で呼ぶ。この場では呼び出さない
function disposeChannel() {
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(8.1.6・2026年10月15日版)とweb-featuresの記録では、対応は次のとおりです。最後に対応したのはSafari 15.4(2022年3月14日)で、そこから30か月を経た2024年9月14日にBaselineの「広く利用可能」へ移りました。iPhone・iPadのSafariも同じ15.4からです。
| 環境 | 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でグローバル化 | 対応 |
| Deno | 2.6以降 | – |
仕様のIDLは [Exposed=(Window,Worker)] なので、Web Workerの中でも同じ名前のチャンネルを作ればタブと直接やり取りできます。Node.jsでは worker_threads のスレッド間通信として使え、18.0.0で実験的扱いが外れました。
機能検出と古いブラウザへの対応
対応の確認は、グローバルにコンストラクタがあるかを見るだけで足ります。window ではなく self で調べておくと、同じコードがWorkerの中でも動きます。
if ('BroadcastChannel' in self) {
const channel = new BroadcastChannel('myapp:auth:v1');
// 通常の処理
} else {
// iOS 15.3以前など。localStorageへの書き込みとstorageイベントで代用する
}
代用に使う storage イベントは、値が文字列に限られ、書き込んだタブ自身では発火しません。iOS 15.3以前を切れない案件でなければ、この分岐自体を書かずに済みます。
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() {
const response = await fetch('/api/logout', { method: 'POST' });
if (!response.ok) {
console.error('ログアウトに失敗しました:', response.status);
return;
}
auth.postMessage({ type: 'logout', at: Date.now() });
location.assign('/login'); // 送信したauth自身には届かないので自分で遷移する
}
チャンネル名に myapp: のような接頭辞と :v1 のような版を付けておくと、同じオリジンで動く別アプリとの衝突を避けられ、メッセージ形式を変えるときも旧版のタブと混線しません。
同一オリジンで動くスクリプトなら誰でも同じ名前のチャンネルに参加できます。アクセストークンや個人情報は流さず、「ログアウトした」「カートが更新された」という事実だけを送り、中身は受信側がサーバーや保存先から読み直してください。
Web Locksと組み合わせたリーダータブの選出
BroadcastChannelは全タブに届くだけで、「どのタブが処理するか」は決めてくれません。トークンの更新やWebSocket接続のように1つのタブだけが持つべきものは、Web Locks APIで排他ロックを取れたタブをリーダーにし、リーダーが受けた結果をBroadcastChannelで残りのタブへ配ります。Web LocksはChrome 69・Firefox 96・Safari 15.4・Edge 79以降が対応し(Baselineの「広く利用可能」は2024年9月14日)、HTTPSなどのセキュアコンテキストでしか使えません。BroadcastChannelが動く環境でも navigator.locks が無いことがあるので、HTTPの検証環境では注意してください。
const channel = new BroadcastChannel('myapp:sync:v1');
let release; // 保持中のロックを手放す関数
let controller; // 待機中の要求を取り下げるためのもの
function requestLeadership() {
controller = new AbortController();
navigator.locks.request('myapp:leader', { signal: controller.signal }, () => {
startLeader(); // 例:WebSocketを開き、受けた更新をchannel.postMessage()で配る
return new Promise((resolve) => { release = resolve; }); // 解決するまでロックを持ち続ける
}).catch((e) => { if (e.name !== 'AbortError') throw e; });
}
requestLeadership();
window.addEventListener('pagehide', () => {
controller.abort(); // 待機中なら要求を取り下げる(取得後は無視される)
release?.(); // リーダーならロックを手放す
release = undefined;
stopLeader(); // startLeaderとstopLeaderはアプリ側で用意する
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) requestLeadership(); // bfcacheから戻ったら並び直す
});
Chrome 155で3つのタブを開き、リーダーのタブを閉じると、次に待っていたタブが0.1秒以内にロックを得ました(2回とも)。タブを閉じればロックは自動で解放されるので、ここまでは pagehide の処理がなくても動きます。
bfcacheに入ったリーダーがロックを離さない問題
危ないのは、リーダーのタブが閉じられずに別のページへ移動したときです。pagehide の処理を入れずにChrome 155(ヘッドレス)で試すと、リーダーのページはロックを持ったままbfcacheに入り、先に待っていたタブは41秒待ってもロックを得られませんでした(2回とも)。後から新しく開いたタブが同じロックを要求したときだけ、キャッシュ中のページが破棄されてロックが渡り(約60ミリ秒)、戻ったページの notRestoredReasons には weblocks-contention が記録されていました。この検証で確かめたのはリーダーの引き継ぎが止まることまでで、リーダーが持つWebSocket接続などがその間どうなるかは、接続の終了・再開処理の書き方によります。
上のコードのように pagehide でロックを手放し、待機中の要求も AbortController で取り下げると、リーダーが離れた瞬間に次のタブが引き継ぎ(3〜22ミリ秒)、離れたページもbfcacheから復元されました。ChromeのPage Lifecycle APIの解説も、ページが凍結される前に保持中のWeb Locksを解放するよう勧めています。
ReactとVueで使う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カスタムフックのベストプラクティスにまとめています。
VueUseのuseBroadcastChannelの戻り値
Vueでは自作せず、VueUseの useBroadcastChannel を使えます。@vueuse/core 15.0.0のソースを読むと、戻り値は isSupported・channel・data・post・close・error・isClosed の7つで、チャンネルはマウント時に作られ、スコープの破棄時に自動で close() されます。
import { watch } from 'vue';
import { useBroadcastChannel } from '@vueuse/core';
const { isSupported, data, post } = useBroadcastChannel({ name: 'myapp:cart:v1' });
watch(data, (msg) => {
if (msg?.type === 'cart:updated') count.value = msg.count; // countはコンポーネント側のref
});
data は最後に受け取った1件を入れる shallowRef で、履歴は持ちません。Vue 3.5.44とNode.js v26.5.0で別のチャンネルから3通続けて送ったところ、watch は既定のタイミングでも3件すべてを受け取りました。メッセージはそれぞれ別のタスクで届くためです。isSupported が偽の環境では post を呼んでも何も起きないので、代用の処理が要るならここで分岐します。
bfcacheとの関係:キャッシュ中のメッセージによるページ破棄
HTML仕様は、メッセージの宛先を「eligible for messaging」なオブジェクト、つまり文書が完全にアクティブなウィンドウか停止していないWorkerに限っています。戻る・進むキャッシュ(bfcache)に入ったページは完全にアクティブではないので、宛先から外れ、その間のメッセージは受け取れません。
Chrome 155(ヘッドレス)で、リスナーを登録したページAから別ページへ移動し、別タブから同じチャンネルへ1通送ってから戻る、という手順を各条件4回試した結果が次の表です。1巡目に検証したChrome 154と同じ結果でした。
| ページAの状態 | bfcache中の送信 | 戻ったとき |
|---|---|---|
| リスナーあり | なし | bfcacheから復元 |
| リスナーあり | 1通 | 再読み込み(メッセージは消失) |
| pagehideでclose()済み | 1通 | bfcacheから復元 |
| BroadcastChannelなし | 1通 | bfcacheから復元 |
リスナーを持っているだけではbfcacheは妨げられません。問題はbfcache中にメッセージが届いたときで、ページはキャッシュから捨てられ、戻ると再読み込みになります。通知の多いアプリほど、「戻る」がキャッシュからの即時表示でなく再読み込みになりやすくなります。
本番でこれが起きているかは、戻ったページで performance.getEntriesByType('navigation')[0].notRestoredReasons を読めば分かります。上の2行目の条件では、Chrome 155の reasons に broadcastchannel-message が入っていました。この理由名はHTML仕様のNotRestoredReasonsで「bfcacheに入っている間に、ページ上のBroadcastChannelがメッセージを受け取り、messageイベントが発火した」と定義されています。このAPIはChromeとEdgeが対応し、FirefoxとSafariは未対応です。
対処は、pagehide でチャンネルを閉じ、pageshow の persisted が真のときに開き直す形です。前章で引いたChromeのPage Lifecycle APIの解説も、凍結前にやるべきこととしてBroadcastChannelの接続を閉じることを挙げています。閉じていた間の通知は届かないので、復元時に最新の状態を保存先から読み直します。
// 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連携 |
| MessageChannel | ポートを渡した相手1つ | なし | 1対1の専用回線 |
| SharedWorker | 接続した同一オリジンのタブ | Worker内で保持 | 接続や状態の共有 |
| WebSocket / SSE | サーバー経由で他端末 | 保存は別途実装 | 他ユーザー・他端末 |
storage イベントは値の保存に付随して発火する仕組みで、書き込んだタブ自身では発火せず、値も文字列に限られます。通知だけならBroadcastChannelのほうが、保存先への書き込みが不要で構造化データも送れ、送信元以外なら同じタブ内のインスタンスにも届きます。後から開いたタブにも状態を渡したいなら、保存はlocalStorageやIndexedDBが担い、通知だけをBroadcastChannelで流します。
MessageChannel は2つのポートを作り、片方を postMessage() の転送リストで相手へ渡して使う1対1の回線です。名前で誰でも参加できるBroadcastChannelと違い、ポートを渡した相手としか話せず、オリジンが違うiframeとも使えます。ポートの postMessage() は転送リストを受け付けるので、BroadcastChannelではできない ArrayBuffer のコピーなしの受け渡しもできます。
HTML仕様自身が、共有状態のロック管理や1本のWebSocket接続を複数タブで共有するような込み入った用途にはSharedWorkerが最適だと書いています。SharedWorkerは長らくChrome for Androidで使えないことが採用の壁でしたが、browser-compat-dataではChrome for Android 148で対応し、web-featuresのBaselineは2026年5月5日に「新たに利用可能」になりました。ただし「広く利用可能」になるのは30か月後で、Chrome for Android 69〜147を対象に含める案件では、SharedWorkerに未対応のため、前章のBroadcastChannelとWeb Locksの組み合わせを選べます。他ユーザーや他端末へ届けたいなら、そもそもブラウザ内の仕組みでは足りず、SSEやWebSocketが必要です。サーバー側の配信や外部システムとのつなぎ込みまで含めて設計する場合は、API開発・システム連携の支援も行っています。
よくある質問
BroadcastChannelはSafariやiPhoneで使えますか?
使えます。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に保存し、開いたときに読み込みます。