Apple の APNs の文書には、「APNs は bundle ID ごとに1件の通知しか保持しない」と書かれています。Google の FCM の文書には、オフラインの Android 端末が保持する折りたたみ不可のメッセージは最大100件で、上限に達するとすべて破棄される、と書かれています。どちらの文も、プッシュをスマートフォンへのメッセージキューのように扱い、変更を1件ずつ運ばせる設計とは合いません。

その設計は、机の上ではうまくいきます。そのあと誰かが1時間機内モードにしたり、アプリを強制終了したり、サーバーが1分に20件の更新を送ったりします。以下では、2つのサービスが文書化している内容を一覧にし、クライアントが何をしなければならないかをシミュレーションで示します。

TL;DR

  • Apple は、APNs がベストエフォートで、通知の順序を入れ替えることがあり、オフラインの端末については 1つの bundle ID につき1件しか保持しないと文書化しています。Google は、FCM が順序を保証せず、オフラインの Android 端末に対して折りたたみ不可のメッセージを最大100件までしか保持せず、上限を超えるとすべて破棄すると文書化しています。
  • iOS のバックグラウンド(サイレント)通知は、優先度が低く、配信は保証されず、頻繁だと間引かれ、ユーザーがアプリを強制終了すると破棄されると文書化されています。
  • つまりプッシュは、失われ、新しいものに置き換えられ、自分の再送で重複し、遅れて届きます。ペイロードをデータとして扱うクライアントは、そのどれからも回復できません。
  • プッシュはヒントとして設計します。「何かが変わった。カーソルはこれ」と伝えるだけです。クライアントは自分のカーソルから取得します。アプリを開いたときと同じコードを使います。取得のきっかけには、プッシュに頼らないものを少なくとも1つ足します。アプリのフォアグラウンド化や定期的な同期などです。
  • 損失のあるチャネルのシミュレーションでは、ペイロードを適用する方式が収束したのは1000回中77回、ヒントのあとに取得する方式は855回、それにフォアグラウンドの同期を1回足すと1000回中1000回でした。数値は僕が作ったチャネルのものです。APNs や FCM の配信率ではありません。

文書が「起こりうる」と言っていること

状況文書の記述クライアントにとっての意味
端末がオフラインAPNs は通知を保持することがあり、apns-expiration に応じて最長30日で、端末が次にオンラインになったときに配信します。bundle ID につき1件だけ保持し、多くは最新のものですが、「常に保証されるわけではありません」。5件の変更に対してプッシュが1件しか届かないことがあります。
端末がオフライン(Android)FCM は端末が接続するまでメッセージを保持します。折りたたみ不可のメッセージは、Android では100件まで。超えると保持中のものはすべて破棄され、あとでアプリは全同期を促す特別な通知を受け取ります。100件の変更で何も届かず、その後「取りこぼした」という呼び出しが来ます。
折りたたみ折りたたみ可能なメッセージは、未配信のものを置き換えます。FCM は登録トークンごとに最大4つの collapse key しか保持しません。通知メッセージは常に折りたたみ可能です。古いペイロードは仕様として消えます。
順序APNs は「同じデバイストークンに送った通知の順序を入れ替えることがある」とし、FCM は「配信の順序を保証しない」としています。到着順にペイロードを適用すると、逆順に適用することがあります。
有効期限apns-expiration が 0 なら1回だけ試して保持しません。FCM の ttl が 0 なら、すぐ配信できなければ破棄します。FCM の既定の保持期間は4週間で、それより長くオフラインだと破棄されます。短い有効期限は、意図的な破棄です。
iOS のバックグラウンド更新バックグラウンド通知は優先度が低く、「システムは配信を保証しません」。間引かれることがあり、Apple は1時間に2、3件を超えないよう求めています。新しいものは保持中のものを置き換えます。アプリが強制終了されると、保持中のものは破棄されます。サイレントプッシュは合図にすぎず、強制終了で破棄されうるものです。
優先度と Doze(Android)通常優先度のメッセージは Doze 中に遅れることがあります。目に見える通知にならない高優先度のものは優先度を下げられることがあります。ハンドラーに与えられる時間は数秒です。タイミングは予測できず、長い処理はジョブに回します。
アプリの削除FCM はメッセージを破棄し、トークンを無効にします(HTTP v1 API では UNREGISTERED、旧 API では NotRegistered)。死んだトークンは取り除きます。

Apple と Google の現在の文書から集めました。重要なのは個々の行ではありません。どの行も、「送ったペイロード」と「アプリが見たペイロード」が食い違う経路だということです。

プッシュをヒントとして扱う

プッシュが失われ、置き換えられ、遅れ、順序が入れ替わるなら、正しさをそれに依存させることはできません。プッシュに残る仕事は一番安いものです。アプリを起こして「サーバーに聞いて」と伝えること。真実はサーバーが持ち、順序のある変更ログを持ちます。クライアントはカーソルを持ちます。

// サーバー:真実がある唯一の場所
changes(since) {
  if (log.length && since < log[0].seq - 1) return { reset: true, ...this.snapshot() };   // ログがそこまで遡れない
  return { cursor: seq, events: log.filter((e) => e.seq > since) };
}

// クライアント:プッシュは取得する理由にすぎない
const hintClient = (server) => {
  const s = { items: {}, cursor: 0 };
  const pull = () => {
    const r = server.changes(s.cursor);
    if (r.reset) { s.items = { ...r.items }; s.cursor = r.cursor; return; }
    for (const e of r.events) if (e.seq === s.cursor + 1) { applyOp(s, e); s.cursor = e.seq; }   // 連続するものだけ
  };
  return { s, onPush: (msg) => { if (msg.hintCursor > s.cursor) pull(); }, sync: pull };
};

プッシュのペイロードは { hintCursor } だけです。クライアントは自分のカーソルと比べ、遅れていれば取得します。重複したプッシュは何もせず、古いプッシュも何もせず、別のプッシュを追い越したプッシュは、両方をまとめて取得する1回の取得になります。イベントは次の連番のときだけ適用するので、欠落を黙って飛び越えることはありません。サーバーがログを、クライアントのカーソルより先まで切り詰めていたら、リセットとスナップショットで答えます。

シミュレーション

今回は APNs や FCM にアクセスできなかったので、チャネルはモデルです。上の表で文書から引用した動作だけを実装しました。損失、重複、ランダムな遅延。オフライン端末に対する「最新のものだけ保持」。「上限を超えるとすべて破棄」です。サーバーは30件の変更を適用し、ログには8件だけ残します。チャネルごとに1000通りのシードで、2種類のクライアントを比べました。

channel      payload-applied    hint + pull    hint + pull + 1 foreground sync
lossy        77/1000 converged  855/1000       1000/1000
keepLatest   4/1000 converged   1000/1000      1000/1000
overflow     0/1000 converged   0/1000         1000/1000

ここでのペイロード方式は、わざと素朴に作ってあります。届いたものを、連番の確認も取得もなしにそのまま適用します。連番を足せば欠落には気づけますが、足りない分を取りに行く手段は結局必要で、それが取得です。結果は次のように読んでください。

  • ペイロードを適用する方式は、ほとんどサーバーの状態に届きません。損失、重複、順序の入れ替えが、それぞれ別の形でこれを壊します。
  • ヒント + 取得は、損失のあるチャネルではほぼ正しく、keepLatest では完全に正しくなります。残った1件のプッシュだけで全体の取得が始まるからです。最後のプッシュが失われた場合(損失のあるチャネルで145回)と、何も届かない場合(overflow)は失敗します。取得が一度も始まらないからです。
  • フォアグラウンドの同期1回で、すべての欠落が埋まります。これが実用上の結論です。プッシュは動いているとき、アプリをすばやく新しくします。独立したきっかけが、動かなかったときにも正しさを保ちます。

正確な件数は、僕が選んだ損失率と遅延(損失30%、重複10%、最大4ステップの遅延)に左右されます。push-hint.mjs で変えれば、ヒントだけの列は動きます。パターンは変わらないはずです。

プッシュがヒントの場合と、プロダクトそのものの場合

アプリの状態がサーバーにあり、画面が古いことがバグになるなら、プッシュをヒントにします。メッセージング、共同編集、タスク一覧など、あらゆる同期です。まとめて送れるので、プッシュの量も抑えられます。

プッシュそのものがプロダクトの場合、たとえばワンタイムコードや、文面がすべての時間依存のアラートでは、ペイロードに内容を載せ、有効期限と優先度を意図して設定します。その場合でも、アプリの状態をそれに依存させないでください。目に見える通知と状態の変更は、別の仕事です。

シミュレーションを動かす

mkdir push-sim && cd push-sim
# 実験の push-hint.mjs と push-hint.test.mjs をここに置く
node push-hint.mjs
node --test push-hint.test.mjs

ペイロード方式がなぜ失敗するかを見るには、payloadClient の onPush に受信内容を出力する行を足して、run({ channel: 'lossy', seed: 7 }) を1回実行してください。

プッシュの設計が崩れる場面

  • 状態をペイロードに載せること。 症状: 2台の端末が違うデータを表示しますが、どちらも通常の意味で古いわけではありません。別々の部分集合を適用したためです。対処: 新しい値ではなくカーソルかバージョンを送り、値は取得で返します。
  • プッシュに依存しない取得のきっかけがないこと。 症状: シミュレーションの overflow の行です。オフライン中のバーストのあと何も届かず、ほかの何かが促すまでアプリが古いままになります。対処: フォアグラウンド化、再接続、理由を説明できる間隔のタイマーで同期します。
  • 次ではないイベントを適用すること。 症状: 順序が入れ替わった2件のあと、状態が微妙に狂い、直りません。対処: cursor + 1 だけを適用し、それ以外なら取得し直します。
  • 遡れないログで、スナップショットの経路がないこと。 症状: しばらくオフラインだったクライアントが、サーバーがすでに切り詰めたイベントを求めて、エラーや空の応答を受け取ります。対処: リセットとスナップショットを返し、テストで保持期間を縮めて確かめます。
  • 変更ごとにサイレントプッシュを送ること。 症状: iOS が間引きます。Apple は1時間に2、3件を超えないよう求めています。対処: サーバーでまとめます。バーストごとに1件のヒントで、10件分と同じ効果があります。
  • onMessageReceived で長い処理をすること。 症状: ハンドラーが途中で打ち切られます。Google は、使える時間は数秒で、それ以上は WorkManager を使うよう勧めています。対処: ハンドラーでは同期ジョブを予約し、取得はそこで行います。
  • 死んだトークンを持ち続けること。 症状: 送信失敗が増え、無駄な処理が増えます。対処: FCM が未登録と報告したトークンを削除し、FCM が提供する配信データで破棄されたメッセージを確認します。

計測したことと、モデルにすぎないこと

確認したことです。シミュレーションを Node.js 22.23.3 で動かしました(node push-hint.mjs と、node --test による3つのテスト。全チャネル・全シードでヒント + 同期が収束すること、ペイロード適用が収束しないこと、取得のきっかけがなければ stale のままになることを確かめます)。上の表については、Apple と Firebase のページを読みました。apns-expiration、bundle ID につき1件の保持、100件の上限、強制終了の記述を含みます。

確認していないことです。実際の APNs や FCM の挙動すべて。チャネルは文書の記述のモデルで、配信の計測ではありません。iOS や Android のアプリは動かしていません。自分のアプリの実数が欲しい場合、FCM は照会できる配信データを提供しており、APNs では apns-id と Apple が案内する指標が手がかりです。僕はそれらを使っていません。

届かないプッシュを前提にアプリを作る

両サービスの文書が描くのは、ベストエフォートの合図です。合図だけで足りるようにアプリを作ります。サーバーからのカーソル、どのきっかけでも走らせられる取得、ログが先に進んでしまった場合のスナップショットです。