はじめに
「管理画面では送信成功。配信ログにもエラーはなく、ステータスは 200。なのに自分のスマホには何も表示されない。」
プッシュ通知を運用していると、この状況にぶつかることがあるかもしれません。
しかも、どこにもエラーが出ない。ログはきれい、APIは成功、ダッシュボードの数字も問題なし。
でもプッシュ通知は届かなかった...とても困るケースです。
これは「バグが見つからない」のではなく、エラーにならないまま静かに落ちている状態です。
FCM でプッシュ通知を運用する際は、次の落とし穴を常に意識しておきたいです。
「送信成功」は、郵便局が手紙を受け付けたという意味でしかありません。ポストに届いた、または読まれたとは限らないのです。
「送信成功」から画面表示までの間には、脱落しうる関門が何段も挟まっています。
しかも Web(Push API)特有の「届いた"後"の表示で死ぬ」パターンは、Android 前提・配送インフラ寄りの公式ドキュメントではごっそり抜けています。
この記事では、「プッシュ通知がどこで死んだのか」を切り分ける手順を組み立てます。
切り分けさえできれば調べる範囲は一気に狭まり、「これは自分のコードのせいではない」と見極めて公式に委ねる判断もできるようになります。
対象読者: 自分のサイトにFCMでWebプッシュ通知を導入していて、「送ったはずなのに届かない」の調査方法を知りたいエンジニア。チュートリアルは動かした前提で進めます。
1. 通知が届くまでの流れと、最初の切り分け
原因を当てにいく前に、送信リクエストからユーザーの目に入るまで、通知が通る流れを押さえましょう。
送信サーバー
│ ① send(送信リクエストを投げる)
▼
FCM
│ ② 受理 ← ここが「送信成功」。郵便局が手紙を受け付けた地点
▼
プッシュサービス(ブラウザベンダーの配信網)
│ ③ 端末へ配送
▼
端末 / ブラウザ
│ ④ Service Worker の push イベントが発火 ← 手紙がポストに届いた地点
▼
Service Worker
│ ⑤ showNotification() で通知を組み立てて表示
▼
画面(ユーザーが見る/気づく)← 手紙が開封された地点
③のプッシュサービスは Chrome や Firefox などブラウザベンダーが持つ配信網です。
FCM はこの標準の仕組み(Web Push)に乗っかって端末まで運んでもらうので、FCM がリクエストを受け取り、ブラウザにプッシュ通知を表示させる流れになります。
④の push イベントとは、プッシュ通知を受け取ったとき、 Service Worker が発火させるイベントです。
「通知が端末に届いた」瞬間そのものであり、「pushが発火したか」が配送系と表示系を分ける境界線になります。
大事なのは、受理(②)より後は、どの段でも通知が静かに脱落しうるということ。
送信リクエストそのものが弾かれれば FCM がエラーを返しますが、受理された後はどこも声を上げません。
しかもどの段で落ちたかによって、調べる場所も、そもそも自分に直せる問題なのかも、まったく変わってきます。
最初の切り分け ―「push は発火したか」
そこで、最初に確かめる問いを1つだけ決めます。
Service Worker の
pushイベントが発火したか。
これが「配送系の問題」か「表示系の問題」かを分ける境界線です。
send → 受理(200) → プッシュサービス → 端末 ─┃─→ push発火 → 表示 → ユーザー
┃
【 届く"前" に死ぬ = 配送系 】 ┃ 【 届いた"後" に死ぬ = 表示系 】
トークン失効 / TTL切れ / ┃ OS設定 / showNotification失敗 /
ブラウザ未起動 など、宛先・インフラ側 ┃ 自分の Service Workerの不具合など
-
pushが発火しない → 配送系。Service Worker の外側の問題で、公式ドキュメントの守備範囲です -
pushは発火するのに画面に出ない → 表示系。設定・OS・自分の Service Worker の問題で、公式が手薄な領域です
この問いが効くのは、pushが発火する/しないの二択で、通知が届かなかったときに調べる範囲を半分に絞れるからです。
発火を確かめる観測コードを、push の入口に置く
では「push が発火したか」をどう知るか。
答えはService Worker の push ハンドラに観測コードを仕込むことです。
サーバーへ小さいリクエストを投げる、いわゆる beacon(ビーコン)ですね。
self.addEventListener("push", (event) => {
// waitUntil: 中の非同期処理が終わるまで Service Worker を眠らせないための宣言
event.waitUntil(handlePush(event));
});
async function handlePush(event) {
// ★ 切り分け用のビーコン:まず「push が発火した」ことだけを記録する
// コツは2つ。ほかの処理より "先" に呼ぶ/レスポンスを待たず(await せず)次へ進む
reportBeacon("push_received");
const data = parsePayload(event); // ← ここで失敗しても、push はもう発火している
await self.registration.showNotification(data.title, { body: data.body });
}
// ビーコン:どこまで来たかを、レスポンスを待たずに送る
// navigator.sendBeacon() を使わないのは、Service Worker では使えないため
function reportBeacon(name) {
fetch("/beacon", {
method: "POST",
body: JSON.stringify({ event: name }),
keepalive: true, // Service Worker が停止しても送り届けてもらう
}).catch(() => {}); // 送信が失敗しても本処理は止めない
}
※サンプルコードのため、エラーハンドリング等は省略しています。
ペイロード解析やトークン取得より手前で呼んでおけば、その後の処理がどこで静かに失敗しても、「push は発火した」という事実だけは必ず残せます。
届かない通知に出くわしたら、まず「ビーコンは来ているか?」を見るだけで、配送系か表示系かを切り分けられます。
なお、ビーコン自体もネットワークや広告ブロッカーで落ちることがあるので、「来ない=100%配送系」と断定はできません。それでも切り分けの第一歩としては十分に強力です。
2. 届く"前"に死ぬ ― 配送系
ビーコンがまったく来ないなら、通知は Service Worker にたどり着く前に消えています。境界線の左側、配送系です。
ここで起きることは大きく3つ。
- ① 宛先が「空き家」(トークン死):ユーザーがサイトデータを消した/PWA をアンインストールした/ストレージを自動削除された、などで FCM トークンは簡単に無効になります
- ② 端末がそのとき受け取れない:オフライン、デスクトップでブラウザ未起動など。プッシュサービスは一定期間預かりますが、TTL(有効期限)を過ぎれば破棄されます
- ③ 購読ごと無効:VAPID キー(Web Push の認証鍵)を差し替えると、それまでの購読はすべて無効になります
いずれも push が発火するより手前の話なので、Service Worker のコードを見ても手がかりは出ません。
対処する場所もそれぞれ別(トークン管理、送信側設定、ユーザー環境)で、深掘りは公式にゆずります。
トークンのライフサイクル(保存・失効検知・サーバー同期)や、TTL・collapse・オフライン時の挙動は、公式のブログやドキュメントが詳しいです。
3. 届いた"後"に死ぬ ― 表示系
ビーコンは来ている。つまり push は発火した。なのに画面には出ない――ここからが本題です。
境界線の右側、表示系。原因の多くは、自分の Service Worker かユーザー側の設定にあります。
まず、この右側をもう一段切り分けます。
showNotification()まで到達できたのか?
- 到達していない → 自分の Service Worker の問題
- 到達したのに見えない → OS・ブラウザ・ユーザー設定の問題
「通知が作られていない」― 静かに死ぬコード
push ハンドラは、通知を表示するまでに何段も処理を挟みます。ペイロードのJSON解析、トークン読み出し、表示オプションの組み立て、最後に showNotification()。この途中で失敗すると通知は作られません。
try/catch で受けて console.error に出すのは自然な書き方ですが、Service Worker のログは専用の開発者ツールをつないでいない限りどこにも残りません。
本番でそれを開いている人はいないので、console.error は実質どこにも残らず、サーバーにもログは届きません。
対策は切り分け用ビーコンと同じ発想です。失敗しうる地点それぞれに、「なぜ表示に至らなかったか」を理由付きで送ります。
async function handlePush(event) {
reportBeacon("push_received"); // reportBeacon の定義は前掲と同じ
let data;
try {
data = parsePayload(event);
} catch {
reportBeacon("parse_failed"); // ← 解析で死んだことを記録
// Service Workerは push を受けたら必ず通知を出す義務があるため、失敗時はフォールバック通知を試みる
// ※エラー時の文言は "…" をサービスに合わせて設計する
await self.registration.showNotification("…", { body: "…" });
return;
}
// この後 data で通知を組み立てる。
// トークン取得や showNotification も、同じように成否を個別に記録する
}
こうすれば「push は来た(ビーコンあり)が parse_failed で止まった」とサーバー側で分かります。
「通知が作られたのに見えない」― OSと設定の壁
理由付きビーコンは、失敗だけでなく成功も記録しておくのがコツです。
showNotification() の直後に「作れた」を表すビーコン(push_queued)を送れば、それが来ているかどうかで「作られていない側」か「作られたのに見えない側」かを切り分けられます。
「通知が作れた」のに画面に出ない――ここから先は OS とブラウザの領域です。
- OSのおやすみ/集中モード:通知は作られているのに、バナーや音が抑制される
- OS・ブラウザ側の通知オフ:ユーザーが設定でサイト通知を切っている
-
tag の上書き:同じ
tagの通知は後から来たもので上書きされる。短時間の連投は最後の1つしか残らない - 通知数の制限:OSやブラウザが同時表示数を制限し、古いものが折りたたまれる
このあたりが「出したのに見えない」の原因として有力な候補です。
「表示できた」は「見られた」ではない
表示系でいちばん誤解されやすい点です。
showNotification() の成功は、通知をOSの通知キューに積めたという意味でしかありません。
ユーザーが実際に目にしたかまでは、Service Worker からは分かりません。
つまり push_queued の数(表示数)は、実際に見られた数の上限値として扱うべきものです。
おやすみモードや通知オフで抑制されたぶんは、表示数には乗っていてもユーザーには見えていません。
これはブラウザ Push の構造的な制約で、「表示数は出ているのに反応がない」ときは、この「表示≠閲覧」のギャップを疑ってください。
4. 手を動かすデバッグ
Chrome の DevTools と、これまで仕込んできたビーコンを使って、実際にデバッグしてみましょう。
push を手動で発火させる
毎回サーバーから実際の通知を送るのは大変ですよね。実は DevTools から push イベントを手動で発火できます。
- DevTools を開き、Application タブ → Service Workers を選ぶ
- 対象の Service Worker の Push 入力欄に、自分のハンドラが解析できる形の JSON を入れて「プッシュ」を押す
{ "title": "新着メッセージ", "body": "テスト通知です" }
- 通知が届く
これでサーバーを介さず push ハンドラを動かせます。
ペイロードをわざと壊せば parse_failed の経路もその場で再現できます。
Service Worker のログを見る
本番では消えるログも、手元で手動発火させている間なら見られます。
ただし Service Worker のログはページのコンソールに出ないので、専用の開発者ツールを開きます。
- chrome://serviceworker-internals/ にアクセスし、対象の Service Worker の inspect をクリック
- 別ウィンドウで開く専用の開発者ツールの Console タブに、ハンドラ内の
console.logが流れます。
ビーコンのログを読む ― どこで通知が消えたか
通知が届かなかった場合、サーバー側に届いたビーコンをこう読みます。
| 届いているビーコン | 読み取れること |
|---|---|
| 何も無い | 配送系。push すら発火していない(宛先・インフラ側) |
push_received だけ |
push は発火したが、通知を作る前に死んだ(自分の Service Worker) |
push_received + parse_failed など |
どの処理で死んだかが名指しで分かる |
push_received + push_queued(=「作れた」) |
通知は作れた。見えないなら OS・設定側 |
「どこまで来て、どこで途切れたか」が分かれば、あとは該当箇所を見にいくだけです。
配送系(ビーコンが何も来ない)と分かったら、宛先が生きているかをストレージで確認します。
FCM の SDK はトークンや購読情報を IndexedDB(firebase を含む名前のDBが目印)に保存しているので、DevTools の Application → IndexedDB で「トークンがそもそも入っているか」「サーバー登録済みのものと食い違っていないか」を見てみましょう。
空だったり古いままなら「宛先が空き家」の線が濃厚です。
最小の計測セット
最後に、これだけは仕込んでおきたい最小のビーコンです。
-
push_received…pushハンドラの入口(切り分けの起点) -
push_queued…showNotification()の直後(OSのキューに積めたかどうか) -
失敗理由 …
parse_failed/token_failedなど、死にうる地点ごと
これで「届かない」が起きたとき、配送系か・自分の Service Workerか・OS設定かをログを見て切り分けることができます。
まとめ
「送ったのに届かない」は単一の原因ではなく層の問題です。
通知は何段もの関門を通り、そのどこでも静かに脱落しうる。だからどこで死んだかを切り分けることが大事です。
重要なのは push イベントが発火したかです。
発火していなければ配送系(多くは公式の領域)、発火しているのに出ないなら表示系(公式が手薄な領域)です。
まずは push_received を判別するビーコン1つ仕込んでみましょう。
それだけで、通知が届かなかったとき、調べる範囲が半分になります。
ビーコンやDevToolsをうまく活用し、問題の切り分け・デバッグを行っていきましょう。
