この記事でやること
PWAチャットアプリで「通知が届かない」問題を、フォールバック設計で解決します。
- PWAプッシュ → ブラウザ通知 → アプリ内通知の段階的フォールバック
- Service Workerのライフサイクルに起因する2重通知バグの原因と修正
- 購読失効時の自動クリーンアップ(410 Gone対応)
- PWAバッジのIndexedDB永続化
前提
- Next.js 14(App Router)+ Supabase構成
- Web Push API + Service Worker で通知実装済み
- web-pushパッケージを使用
通知が届かないケース
| ケース | 原因 |
|---|---|
| SWが未登録 | ブラウザのストレージクリア・PWA未インストール |
| プッシュ購読の期限切れ | ブラウザ/OS側の自動失効 |
| 通知権限が未許可 | ユーザーがブロック済み |
問題は「SWが未登録だがブラウザは開いている」状態。Web Push APIは使えないが、Notification APIは使える。
フォールバック実装
const sendNotification = async (
title: string,
body: string,
userId: string
) => {
// 1. PWAプッシュ購読があるか確認
const subscription = await getPushSubscription(userId)
if (subscription) {
await fetch("/api/push-send", {
method: "POST",
body: JSON.stringify({ subscription, title, body }),
})
return
}
// 2. ブラウザ通知にフォールバック
if ("Notification" in window && Notification.permission === "granted") {
new Notification(title, { body, icon: "/icon-192x192.png" })
return
}
// 3. アプリ内通知は別ルート(Supabase Realtime経由)で常に処理
}
サーバーサイドのプッシュ送信
// /api/push-send/route.ts
import webpush from "web-push"
webpush.setVapidDetails(
"mailto:your@email.com",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
export async function POST(request: Request) {
const { subscription, title, body } = await request.json()
try {
await webpush.sendNotification(
subscription,
JSON.stringify({ title, body })
)
return Response.json({ success: true })
} catch (error: unknown) {
if (error instanceof webpush.WebPushError && error.statusCode === 410) {
// 購読失効 → DBから削除して次回フォールバックへ
await deleteSubscription(subscription.endpoint)
}
return Response.json({ success: false }, { status: 500 })
}
}
410 Goneが返ったらDBの購読情報を削除。次回からブラウザ通知にフォールバックします。
2重通知バグ
最初の実装(NG)
const sendNotification = async (title: string, body: string, userId: string) => {
if (navigator.serviceWorker?.controller) {
await sendPushNotification(userId, title, body)
} else {
new Notification(title, { body })
}
}
原因
Service Workerのライフサイクル:
インストール → アクティベーション → 制御開始(controller !== null)
PWAプッシュ購読は持っているがcontrollerがまだnullのタイミングが存在します。
- PWAインストール済み → 購読がDBにある
- ページ開いた直後、SWアクティベーション中 →
controllerはnull - ブラウザ通知を送信
- SW起動後、プッシュも届く
- 2重通知
修正
controllerではなく購読の有無で判定。
const sendNotification = async (title: string, body: string, userId: string) => {
const subscription = await getPushSubscription(userId)
if (subscription) {
// 購読あり → プッシュのみ(SWの状態に関わらず)
await sendPushNotification(userId, title, body)
return
}
if ("Notification" in window && Notification.permission === "granted") {
new Notification(title, { body })
}
}
購読がDBに存在する = いずれプッシュが届く。controllerがnullでもブラウザ通知は送らない。
PWAバッジのIndexedDB永続化
PWAがバックグラウンドに回るとファビコンバッジが消える問題をIndexedDBで解決。
const saveBadgeCount = async (count: number) => {
const db = await openDB("team-board", 1, {
upgrade(db) {
db.createObjectStore("state")
},
})
await db.put("state", count, "badgeCount")
}
const restoreBadgeCount = async (): Promise<number> => {
const db = await openDB("team-board", 1)
const count = await db.get("state", "badgeCount")
return count ?? 0
}
document.addEventListener("visibilitychange", async () => {
if (document.visibilityState === "visible") {
const count = await restoreBadgeCount()
updateFaviconBadge(count)
updateTitleBadge(count)
}
})
Badging API対応環境では併用。
const updateBadge = async (count: number) => {
await saveBadgeCount(count)
updateFaviconBadge(count)
updateTitleBadge(count)
if ("setAppBadge" in navigator) {
count > 0
? await navigator.setAppBadge(count)
: await navigator.clearAppBadge()
}
}
まとめ
| 問題 | 解決策 |
|---|---|
| 通知が届かない | 購読有無で判定 → フォールバック |
| 2重通知 |
controllerではなく購読で判定 |
| 購読の失効 | 410 Goneで自動削除 |
| バッジが消える | IndexedDB永続化 + visibilitychange |
PWA通知で一番ハマるのはService Workerのライフサイクルと通知経路の不整合です。購読の有無を唯一の判定基準にすることで「届かない」と「2回来る」の両方を解消できます。
SEOスコアチェックツール: SEO_CHECK — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work