通知が一部のユーザーにだけ届かない
個人開発のアプリで、ある告知をプッシュで送ったとき、開封数が想定よりかなり低く出たことがあります。送信ログ上は「全員に送った」ことになっていました。けれど数人から「通知が来ていない」と連絡があり、自分の予備端末でも届いていないものがありました。送ったのに届かない——この曖昧な状態を、当時の私は「APNs が不調なのだろう」くらいでしか説明できませんでした。
あとから分かったのは、「届かない」は1つの現象ではないということです。トークンが古い、サーバが受理を拒否した、プラットフォームが低優先度で間引いた、端末が表示しなかった——どれも結果は同じ「届かない」ですが、原因も対処もまるで違います。通知を送信から表示までの段に分けて観測し、どこで落ちたかを切り分けて到達率を詰めるための設計を、実装込みで共有します。
「届かない」を段に分解する
最初にやるべきは、ひとつの「届かない」を、独立して落ちうる段に分けることです。各段に観測点を置けば、漠然とした不調が「この段で何件落ちた」に変わります。
| 段 | 落ちる主な理由 | 確認する信号 |
|---|---|---|
| 1. トークン取得 | 許諾されていない/トークン未登録 | 端末の許諾状態・サーバの登録有無 |
| 2. サーバ受理 | 失効トークン・ペイロード不正 | APNs/FCM の応答コード |
| 3. プラットフォーム配信 | 低優先度の間引き・スロットリング | 優先度設定・メッセージ種別 |
| 4. 端末表示 | 強制終了・通知オフ・サイレント処理 | 受信ハンドラの到達ログ |
| 5. 開封 | 気づかれない/関心がない | 開封イベント |
ほとんどの開発者は段5(開封率)だけを見ています。けれど「届かない」の多くは段2〜4で起きていて、そこを観測していないと、開封率の低さが「内容が悪い」なのか「そもそも届いていない」なのか区別できません。以下、落ちやすい段から順に潰していきます。
失効トークンを掃除する(最も静かな殺し屋)
実運用で最も多い「届かない」の原因は、サーバが古いトークンへ送り続けていることです。ユーザーがアプリを消す・再インストールする・OS を更新するとトークンは変わります。古いトークンへ送ると、APNs/FCM は受理時または後続のフィードバックで「無効」と返しますが、それを記録して掃除しないと、無効な宛先へ延々と送り続けることになります。
トークンは「登録」だけでなく「失効処理」までを一対にして設計します。
// register-token.ts — 端末側: トークンを登録し、変化を検知して更新する
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
export async function registerPushToken(userId: string) {
if (!Device.isDevice) return; // シミュレータは実トークンを取れない
const { status } = await Notifications.getPermissionsAsync();
let finalStatus = status;
if (status !== "granted") {
finalStatus = (await Notifications.requestPermissionsAsync()).status;
}
if (finalStatus !== "granted") {
// 段1で落ちる: 許諾なし。サーバ側で「送れない宛先」と記録する
await fetch("https://api.example.com/push/unregister", {
method: "POST",
body: JSON.stringify({ userId, reason: "denied" }),
headers: { "Content-Type": "application/json" },
});
return;
}
const token = (await Notifications.getExpoPushTokenAsync()).data;
await fetch("https://api.example.com/push/register", {
method: "POST",
body: JSON.stringify({ userId, token, platform: Device.osName }),
headers: { "Content-Type": "application/json" },
});
}サーバ側では、送信のたびに返ってくる失敗コードを必ず記録し、無効トークンを止めます。Expo のプッシュ API を使う場合、レシートに DeviceNotRegistered が返ったトークンは即座に無効化します。
// send.mjs — サーバ側: 送信し、失敗コードでトークンを掃除する
import { Expo } from "expo-server-sdk";
const expo = new Expo();
export async function sendBatch(messages) {
const chunks = expo.chunkPushNotifications(messages);
const stats = { accepted: 0, rejected: 0, invalidTokens: [] };
for (const chunk of chunks) {
const tickets = await expo.sendPushNotificationsAsync(chunk);
tickets.forEach((t, i) => {
if (t.status === "ok") {
stats.accepted++;
} else {
stats.rejected++;
// 段2で落ちた。理由コードを必ず残す
if (t.details?.error === "DeviceNotRegistered") {
stats.invalidTokens.push(chunk[i].to); // 後で DB から削除
}
console.error(`reject: ${t.details?.error} token=${chunk[i].to}`);
}
});
}
// 無効トークンを掃除(次回からこの宛先には送らない)
await purgeTokens(stats.invalidTokens);
return stats;
}ここで accepted と rejected を集計値として残すのが要点です。「全員に送った」ではなく「受理 N 件・拒否 M 件・うち失効 K 件」と分かれば、段2での損失が数値になります。私はこの3つを送信のたびにログに残し、失効トークンの割合が跳ねていないかを見ています。割合が急に上がったときは、OS の大型更新やアプリの再インストール増が背景にあることが多いです。