Web アプリを使っていると、同じサービスを複数タブで開くことがあります。
たとえば、タブ A で設定を変更して、タブ B では古い表示のままになっている。
タブ A でログアウトしたのに、タブ B ではまだログイン中に見える。
タブ A で通知を既読にしたのに、タブ B のバッジは残ったままになっている。
どれも致命的なバグではありません。
しかし、ユーザーから見ると「この画面、大丈夫かな」と感じる原因になります。
こうした複数タブ間の小さなずれをブラウザー内でそろえる方法の 1 つが、BroadcastChannel API です。この記事では、BroadcastChannel API を使った複数タブ同期の基本と、実装時に見落としやすい注意点を整理します。
扱う内容は次の通りです。
- BroadcastChannel API で何ができるか
-
storageイベントとの違い - 同じタブ内の別インスタンスにもメッセージが届くこと
- tab ID で自分起点のメッセージを除外する方法
-
close()を呼ぶタイミング - 未対応環境でのフォールバック
- メッセージスキーマを壊しにくくする考え方
複数タブで起きる状態のずれ
まず、問題になる場面をもう少し具体的に見ます。Web アプリでは、同じユーザーが同じアプリを複数タブで開くことは珍しくありません。
たとえば、次のようなケースです。
- タブ A でレコードを編集している途中に、タブ B でも同じレコードを開く
- タブ A でログアウトしたのに、タブ B がそのまま操作できそうに見える
- タブ A で通知を既読にしたのに、タブ B の未読バッジが残る
- タブ A で下書きを保存したのに、タブ B が古い内容を持ち続ける
もちろん、最終的な整合性はサーバー側で担保するべきです。
しかし、すべての変化をサーバーに問い合わせ直すのは重いですし、UI の反映も遅くなります。
「別タブでこういう操作が起きた」という軽い通知だけなら、ブラウザー内で伝えられると便利です。
そこで使えるのが BroadcastChannel API です。
BroadcastChannel API の最小例
BroadcastChannel API は、同一 origin の複数のタブ、ウィンドウ、iframe、worker の間でメッセージを送受信するための Web API です。同じチャンネル名で BroadcastChannel を作ると、そのチャンネルに対してメッセージを送れます。
受信側はこう書きます。
const ch = new BroadcastChannel('app-events');
ch.addEventListener('message', (ev) => {
console.log('received:', ev.data);
});
別のタブから送る側はこうです。
const ch = new BroadcastChannel('app-events');
ch.postMessage({ type: 'logout' });
ch.close();
これで、同じ origin で app-events というチャンネルを開いている他のコンテキストにメッセージが届きます。
ここでいう同一 origin とは、スキーム、ホスト、ポートが一致する範囲です。
https://example.com → 同じ origin
https://example.com:443 → 同じ origin
https://api.example.com → 別 origin
http://example.com → 別 origin
BroadcastChannel は、別 origin のページには届きません。
そのため、同じアプリ内の複数タブを同期する用途に向いています。
送れるデータ
postMessage() には、文字列だけでなくオブジェクトも渡せます。
ch.postMessage({
type: 'notification.read',
notificationId: 'n_123',
});
メッセージは structured clone algorithm でコピーされます。
そのため、通常の JSON 的なオブジェクトだけでなく、Date、Map、Set、ArrayBuffer なども扱えます。
ただし、関数や DOM ノードは送れません。
実務では、複雑なオブジェクトをそのまま流すより、次のような小さなイベントとして扱う方が安全です。
type AppEvent =
| { type: 'logout' }
| { type: 'draft.saved'; recordId: string }
| { type: 'notification.read'; notificationId: string };
BroadcastChannel は「状態そのものを同期する仕組み」ではありません。「別タブで何かが起きたことを知らせる仕組み」にあたります。
storage イベントとの違い
複数タブ間の同期では、昔から storage イベントも使われてきました。
localStorage を変更すると、同じ origin の他のタブで storage イベントが発火します。
localStorage.setItem('logout', String(Date.now()));
別タブでは、次のように受け取れます。
window.addEventListener('storage', (ev) => {
if (ev.key === 'logout') {
// 別タブのログアウトを検知
}
});
これはこれで便利です。
ただし、storage イベントは localStorage への書き込みが前提です。
ログアウト通知や既読通知のような一時的なイベントまで localStorage へ書き込むことになります。
その結果、永続化する必要のない値が storage へ残り、メッセージのためだけにキーが増えていきます。
BroadcastChannel は、この点が違います。
ch.postMessage({ type: 'logout' });
localStorage に何かを書き込む必要はありません。
単に「今このイベントが起きた」と他のタブに知らせられます。
ざっくり使い分けるなら、次のようになります。
- localStorage の変更そのものを伝えたいなら
storageイベント - 一時的なアプリ内イベントを伝えたいなら BroadcastChannel
ログアウト、通知の既読、下書き保存、同一レコードの編集警告のような用途では、BroadcastChannel の方が自然です。
送信元へ戻らないメッセージ
BroadcastChannel を使うときに、少し紛らわしい点があります。postMessage() したメッセージは、送信元の BroadcastChannel オブジェクト自身には届きません。
たとえば、次のようなコードでは、同じ ch の message リスナーには届きません。
const ch = new BroadcastChannel('app-events');
ch.addEventListener('message', (ev) => {
console.log('received:', ev.data);
});
ch.postMessage({ type: 'logout' });
ここだけ見ると、「自分のタブには返ってこない」と考えたくなります。
しかし、正確にはそうではありません。
除外されるのは、送信元の BroadcastChannel オブジェクト 1 つだけです。
送信元のタブ全体が除外されるわけではありません。
同じタブの中で、別々に new BroadcastChannel('app-events') していると、片方から送ったメッセージはもう片方に届きます。
購読用と送信用を分けると起きること
実務では、受信用の関数と送信用の関数を分けることがあります。
たとえば、次のような形です。
function subscribe(onMessage: (data: unknown) => void) {
const ch = new BroadcastChannel('app-events');
ch.addEventListener('message', (ev) => {
onMessage(ev.data);
});
return () => ch.close();
}
function broadcast(data: unknown) {
const ch = new BroadcastChannel('app-events');
ch.postMessage(data);
ch.close();
}
一見、普通の設計に見えます。
しかし、このコードでは同じタブの中に BroadcastChannel オブジェクトが 2 つ存在します。
同じタブ
├─ subscribe 用の BroadcastChannel
└─ broadcast 用の BroadcastChannel
この状態で broadcast() からメッセージを送ると、送信元の broadcast 用インスタンスには返ってきません。
しかし、同じタブ内の subscribe 用インスタンスには届きます。
つまり、自分の送ったメッセージで自分の画面が反応します。
ログアウトのようなイベントでは大きな問題になりにくくなります。
しかし、下書き保存や編集中レコードの通知では、「別タブで起きたこと」と「自タブで起きたこと」を区別したい場面があります。
そのため、実務では送信元を識別できるようにしておくと安全です。
tab ID による自分起点の除外
BroadcastChannel 自体には「どのタブから来たメッセージか」を識別する仕組みはありません。
そのため、アプリ側で tab ID を発番します。
function makeTabId(): string {
return Math.random().toString(36).slice(2);
}
この ID は、厳密なユーザー識別子ではありません。
そのタブを区別できれば十分です。
ページを再読み込みしたら変わっても構いません。
永続化する必要もありません。
ここでは、自分起点のメッセージを見分けるための簡易な識別子として使います。
メッセージには、この tab ID を載せます。
type AppEvent =
| { type: 'logout'; tabId: string }
| { type: 'draft.saved'; recordId: string; tabId: string }
| { type: 'notification.read'; notificationId: string; tabId: string };
受信側では、自分の tab ID と一致するものを無視します。
function subscribeOthers(ownTabId: string, onOther: (event: AppEvent) => void) {
const ch = new BroadcastChannel('app-events');
const onMessage = (ev: MessageEvent<AppEvent>) => {
if (ev.data.tabId === ownTabId) {
return;
}
onOther(ev.data);
};
ch.addEventListener('message', onMessage);
return () => {
ch.removeEventListener('message', onMessage);
ch.close();
};
}
送信側は、必ず自分の tab ID を付けて送ります。
function broadcast(ownTabId: string, event: Omit<AppEvent, 'tabId'>) {
const ch = new BroadcastChannel('app-events');
try {
ch.postMessage({
...event,
tabId: ownTabId,
});
} finally {
ch.close();
}
}
これで、同じタブ内の別インスタンスにメッセージが届いても、自分起点のものとして捨てられます。
BroadcastChannel が使えない環境
BroadcastChannel は多くのモダンブラウザーで使えます。
ただし、どこでも必ず使えるわけではありません。
たとえば、古い Safari、sandboxed iframe、file:// で開いているページなどでは、利用できないことがあります。
そのため、生成処理は安全に包んでおく方がよいです。
function getBroadcastChannel(name: string): BroadcastChannel | null {
if (typeof window === 'undefined') {
return null;
}
if (typeof BroadcastChannel === 'undefined') {
return null;
}
try {
return new BroadcastChannel(name);
} catch {
return null;
}
}
呼び出し側では、null の場合は cross-tab 同期を諦めます。
const ch = getBroadcastChannel('app-events');
if (ch == null) {
// BroadcastChannel が使えない環境では何もしない
return;
}
複数タブ同期は、便利ではありますが、多くの場合は補助機能です。
BroadcastChannel が使えないからといって、アプリ全体を止める必要はありません。
ログアウト整合など、どうしても必要な部分はサーバー側のセッション検証や再取得で担保し、BroadcastChannel は UI の反映を早めるものとして扱う方が現実的です。
close() は明示的に呼ぶ
BroadcastChannel は、使い終わったら close() します。
特に、React コンポーネント内で購読する場合は、unmount 時に閉じるのが重要です。
useEffect(() => {
const ch = getBroadcastChannel('app-events');
if (ch == null) {
return;
}
const onMessage = (ev: MessageEvent<AppEvent>) => {
// メッセージを処理
};
ch.addEventListener('message', onMessage);
return () => {
ch.removeEventListener('message', onMessage);
ch.close();
};
}, []);
購読用の BroadcastChannel は、コンポーネントや機能が生きている間だけ開きます。
不要になったら閉じます。
一方で、送信用に一度だけ使う BroadcastChannel は、postMessage() の直後に閉じます。
function broadcastOnce(event: AppEvent) {
const ch = getBroadcastChannel('app-events');
if (ch == null) {
return;
}
try {
ch.postMessage(event);
} finally {
ch.close();
}
}
考え方は単純です。
- 受信用は、購読をやめるタイミングで閉じる
- 送信用は、送ったらすぐ閉じる
開きっぱなしの BroadcastChannel が増えると、不要なリスナーが残ったり、想定外のメッセージを受け続けたりします。
GC 任せにせず、ライフサイクルを明示しておく方が安全です。
メッセージの形は壊れにくくしておく
BroadcastChannel のメッセージは、同じブラウザー内の別タブに届きます。ここで少し厄介なのは、別タブが同じバージョンのアプリとは限らないことです。
たとえば、次のような状況があります。
- タブ A は古い JS を読み込んだまま開かれている
- 新しいバージョンをデプロイする
- タブ B は新しい JS を読み込んで開かれる
- タブ A とタブ B の間で BroadcastChannel のメッセージが飛ぶ
このとき、古いタブが知らない type のメッセージを受け取ることがあります。
そのため、受信側は未知の type をエラーにせず、黙って無視できるようにしておくと安全です。
type AppEvent =
| { type: 'logout'; tabId: string }
| { type: 'draft.saved'; recordId: string; tabId: string }
| { type: 'notification.read'; notificationId: string; tabId: string };
function handleEvent(event: AppEvent | { type: string; [key: string]: unknown }) {
switch (event.type) {
case 'logout':
// ログアウトを反映
return;
case 'draft.saved':
// 下書き保存を反映
return;
case 'notification.read':
// 通知の既読を反映
return;
default:
// 新しいバージョンのタブから来た未知のイベントかもしれない
return;
}
}
type を discriminator として使い、知っているイベントだけ処理する。
知らないイベントは無視する。
これだけでも、デプロイ直後の新旧タブ混在に強くなります。
大きな変更を入れる場合は、schemaVersion を入れる方法もあります。
type AppEvent = {
schemaVersion: 1;
type: string;
tabId: string;
[key: string]: unknown;
};
ただし、最初から複雑にしすぎる必要はありません。
まずは type を明示し、未知の type を無視する設計にしておくのが扱いやすいです。
実装をまとめる
ここまでの話を踏まえると、最低限の実装は次のようになります。
type AppEvent =
| { type: 'logout'; tabId: string }
| { type: 'draft.saved'; recordId: string; tabId: string }
| { type: 'notification.read'; notificationId: string; tabId: string };
const CHANNEL_NAME = 'app-events';
export function makeTabId(): string {
return Math.random().toString(36).slice(2);
}
function getBroadcastChannel(name: string): BroadcastChannel | null {
if (typeof window === 'undefined') {
return null;
}
if (typeof BroadcastChannel === 'undefined') {
return null;
}
try {
return new BroadcastChannel(name);
} catch {
return null;
}
}
export function subscribeOtherTabs(
ownTabId: string,
onEvent: (event: AppEvent) => void,
): () => void {
const ch = getBroadcastChannel(CHANNEL_NAME);
if (ch == null) {
return () => {};
}
const onMessage = (
ev: MessageEvent<AppEvent | { type: string; [key: string]: unknown }>,
) => {
const event = ev.data;
if (typeof event !== 'object' || event == null || typeof event.type !== 'string') {
return;
}
if ('tabId' in event && event.tabId === ownTabId) {
return;
}
switch (event.type) {
case 'logout':
case 'draft.saved':
case 'notification.read':
onEvent(event as AppEvent);
return;
default:
return;
}
};
ch.addEventListener('message', onMessage);
return () => {
ch.removeEventListener('message', onMessage);
ch.close();
};
}
export function broadcastToOtherTabs(
ownTabId: string,
event: Omit<AppEvent, 'tabId'>,
): void {
const ch = getBroadcastChannel(CHANNEL_NAME);
if (ch == null) {
return;
}
try {
ch.postMessage({
...event,
tabId: ownTabId,
});
} finally {
ch.close();
}
}
React から使うなら、たとえば次のようになります。
const tabId = useMemo(() => makeTabId(), []);
useEffect(() => {
return subscribeOtherTabs(tabId, (event) => {
switch (event.type) {
case 'logout':
// ログアウト状態を反映
return;
case 'draft.saved':
// 必要なら該当レコードを再取得
return;
case 'notification.read':
// 通知バッジを更新
return;
}
});
}, [tabId]);
送信側は、操作が起きたタイミングで呼びます。
broadcastToOtherTabs(tabId, {
type: 'notification.read',
notificationId,
});
これで、「別タブで起きたイベント」を現在のタブに反映できます。
まとめ
BroadcastChannel API を使うと、同一 origin の複数タブ間でメッセージを送受信できます。ログアウト、通知の既読、下書き保存、同一レコードの編集警告など、サーバーに問い合わせ直すほどではないけれど、他のタブにも早めに知らせたい場面で便利です。
ただし、実装時にはいくつか注意点があります。
- BroadcastChannel は同一 origin 内で動く
-
storageイベントと違い、localStorage への書き込みは不要 - 送信元の BroadcastChannel オブジェクト自身には届かない
- ただし、同じタブ内の別 BroadcastChannel インスタンスには届く
- 自分起点のメッセージを除外したいなら tab ID を載せる
- 未対応環境では
nullフォールバックして機能を縮退する - 使い終わったら
close()する - 未知の
typeは無視して、新旧タブの混在に備える
BroadcastChannel は、複数タブ間でイベントを伝える用途に向いた API です。
使うときは、状態そのものを同期しようとしないほうが扱いやすくなります。「別タブで何が起きたか」を知らせ、現在のタブ側で再取得や UI 更新をする形にします。この形なら、状態の整合を保つ責任を整理できます。