Service Workerプッシュ通知実戦:5つのパターンでWeb Push&オフラインアラートシステムを構築
前端工程
Service Workerプッシュ:Webアプリのリアルタイム通知能力
モバイルアプリにはプッシュ通知があるが、Webアプリはポーリングしかできない?Service Worker + Web Push APIにより、Webアプリでもサーバーからプッシュされたリアルタイム通知を、ユーザーがページを閉じていても受信可能。2026年、Push APIはChrome/Firefox/Edge/Safariで完全サポート、Background Sync APIでオフラインメッセージキューを実現、Webプッシュ能力はネイティブアプリに匹敵します。
本記事では5つのコアパターンから、サブスクリプション管理→プッシュ送信→オフライン通知→バックグラウンド同期→通知インタラクションのフルパイプライン実戦を解説します。
コア概念
| 概念 | 説明 |
|---|---|
| Service Worker | ブラウザバックグラウンドで動作するスクリプト、ネットワークリクエストを傍受 |
| Push API | サーバーからクライアントへのプッシュメッセージングAPI |
| Notification API | ブラウザネイティブ通知表示API |
| VAPID | プッシュ認証プロトコル、自己ホスト型プッシュキー |
| Subscription | クライアントプッシュサブスクリプションオブジェクト、endpointとキーを含む |
| Background Sync | ネットワーク復旧後の自動リトライAPI |
| Push Event | Service Workerが受信するプッシュイベント |
| Notification Event | ユーザーの通知クリック/閉じるイベント |
問題分析:Service Workerプッシュの5つの課題
- ブラウザ互換性:SafariプッシュAPIはChromeと大きく異なる
- サブスクリプション管理:デバイス変更/再インストールでサブスクリプションが無効に
- プッシュ暗号化:Payload暗号化が複雑、キー管理が煩雑
- オフラインシナリオ:ユーザーオフライン時のプッシュメッセージ処理
- 通知疲労:プッシュ頻度が高すぎるとユーザーが権限を取り消す
ステップバイステップ:5つのService Workerプッシュパターン
パターン1:VAPIDキーとサブスクリプション管理
// VAPIDキーの生成
// npm install web-push
import webpush from 'web-push';
import { generateKeyPair } from 'crypto';
const vapidKeys = webpush.generateVAPIDKeys();
console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);
webpush.setVapidDetails(
'mailto:admin@example.com',
vapidKeys.publicKey,
vapidKeys.privateKey
);
// フロントエンドサブスクリプション管理
class PushSubscriptionManager {
private registration: ServiceWorkerRegistration | null = null;
async init() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push API not supported');
}
this.registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
}
async subscribe(): Promise<PushSubscription | null> {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return null;
const subscription = await this.registration!.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await this.sendSubscriptionToServer(subscription);
return subscription;
}
async unsubscribe(): Promise<boolean> {
const subscription = await this.registration!.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await this.removeSubscriptionFromServer(subscription);
}
return true;
}
async getSubscription(): Promise<PushSubscription | null> {
return this.registration!.pushManager.getSubscription();
}
private async sendSubscriptionToServer(subscription: PushSubscription) {
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
});
}
private async removeSubscriptionFromServer(subscription: PushSubscription) {
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
}
パターン2:サーバーサイドプッシュ送信
// server/push-sender.ts
import webpush from 'web-push';
interface PushMessage {
title: string;
body: string;
icon?: string;
image?: string;
badge?: string;
tag?: string;
data?: Record<string, unknown>;
actions?: Array<{ action: string; title: string; icon?: string }>;
requireInteraction?: boolean;
silent?: boolean;
}
interface SubscriptionRecord {
endpoint: string;
keys: { p256dh: string; auth: string };
userId: string;
}
class PushSender {
async sendToOne(subscription: SubscriptionRecord, message: PushMessage) {
const payload = JSON.stringify(message);
try {
await webpush.sendNotification(subscription, payload, {
TTL: 86400,
urgency: 'normal',
topic: message.tag,
});
} catch (error: any) {
if (error.statusCode === 410) {
await this.removeInvalidSubscription(subscription.endpoint);
}
throw error;
}
}
async sendToMany(subscriptions: SubscriptionRecord[], message: PushMessage) {
const payload = JSON.stringify(message);
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webpush.sendNotification(sub, payload, {
TTL: 86400,
urgency: 'normal',
})
)
);
const failed = results
.map((r, i) => ({ result: r, subscription: subscriptions[i] }))
.filter(({ result }) => result.status === 'rejected');
for (const { result, subscription } of failed) {
const err = (result as PromiseRejectedResult).reason;
if (err.statusCode === 410) {
await this.removeInvalidSubscription(subscription.endpoint);
}
}
return {
sent: results.filter((r) => r.status === 'fulfilled').length,
failed: failed.length,
};
}
async sendWithSchedule(
subscriptions: SubscriptionRecord[],
message: PushMessage,
scheduledAt: Date
) {
const delay = scheduledAt.getTime() - Date.now();
if (delay <= 0) {
return this.sendToMany(subscriptions, message);
}
setTimeout(() => this.sendToMany(subscriptions, message), delay);
}
private async removeInvalidSubscription(endpoint: string) {
await fetch(`/api/push/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
});
}
}
パターン3:Service Workerプッシュイベント処理
// public/sw.js
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', (event) => {
if (!event.data) {
event.waitUntil(
self.registration.showNotification('新着メッセージ', {
body: '新しいメッセージを受信しました',
icon: '/icons/notification-192.png',
badge: '/icons/badge-72.png',
})
);
return;
}
const data = event.data.json();
const options: NotificationOptions = {
body: data.body,
icon: data.icon || '/icons/notification-192.png',
badge: data.badge || '/icons/badge-72.png',
image: data.image,
tag: data.tag || `notification-${Date.now()}`,
data: {
url: data.url || '/',
...data.data,
},
actions: data.actions || [],
requireInteraction: data.requireInteraction || false,
silent: data.silent || false,
vibrate: data.silent ? undefined : [200, 100, 200],
timestamp: Date.now(),
};
event.waitUntil(
self.registration.getNotifications().then((existing) => {
if (data.tag) {
existing
.filter((n) => n.tag === data.tag)
.forEach((n) => n.close());
}
return self.registration.showNotification(data.title, options);
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
if (event.action) {
event.waitUntil(
self.clients.openWindow(event.notification.data?.[event.action] || urlToOpen)
);
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
const existingClient = clients.find((client) => client.url.includes(self.location.origin));
if (existingClient) {
return existingClient.focus();
}
return self.clients.openWindow(urlToOpen);
})
);
});
self.addEventListener('notificationclose', (event) => {
const tag = event.notification.tag;
event.waitUntil(
fetch('/api/push/notification-closed', {
method: 'POST',
body: JSON.stringify({ tag, timestamp: Date.now() }),
headers: { 'Content-Type': 'application/json' },
})
);
});
パターン4:Background Syncオフラインメッセージキュー
// フロントエンド同期登録
class OfflineMessageQueue {
private db: IDBDatabase | null = null;
async init() {
this.db = await this.openDB();
}
async enqueue(message: { type: string; payload: unknown }) {
const tx = this.db!.transaction('outbox', 'readwrite');
const store = tx.objectStore('outbox');
await store.add({
...message,
timestamp: Date.now(),
synced: false,
});
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-messages');
}
private openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('push-offline-db', 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('outbox')) {
db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
// sw.js - Background Sync処理
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(this.syncOutboxMessages());
}
});
async function syncOutboxMessages() {
const db = await openDB();
const tx = db.transaction('outbox', 'readwrite');
const store = tx.objectStore('outbox');
const messages = await store.getAll();
const unsynced = messages.filter((m) => !m.synced);
for (const msg of unsynced) {
try {
await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg),
});
msg.synced = true;
store.put(msg);
} catch {
break;
}
}
if (unsynced.some((m) => !m.synced)) {
throw new Error('Some messages failed to sync');
}
}
パターン5:プッシュ頻度制御とユーザー設定
// プッシュ頻度コントローラー
class PushFrequencyController {
private limits: Map<string, { count: number; resetAt: number }> = new Map();
async canSend(userId: string, category: string): Promise<boolean> {
const key = `${userId}:${category}`;
const limit = this.limits.get(key);
if (!limit || Date.now() > limit.resetAt) {
this.limits.set(key, { count: 1, resetAt: Date.now() + 3600000 });
return true;
}
const maxPerHour = await this.getUserPreference(userId, category);
if (limit.count >= maxPerHour) {
return false;
}
limit.count++;
return true;
}
private async getUserPreference(userId: string, category: string): Promise<number> {
const pref = await fetch(`/api/push/preferences/${userId}`).then((r) => r.json());
return pref[category]?.maxPerHour ?? 5;
}
}
// ユーザー設定管理API
interface PushPreferences {
enabled: boolean;
categories: {
[key: string]: {
enabled: boolean;
maxPerHour: number;
quietHoursStart?: string;
quietHoursEnd?: string;
};
};
}
class PushPreferenceManager {
async getPreferences(userId: string): Promise<PushPreferences> {
return fetch(`/api/push/preferences/${userId}`).then((r) => r.json());
}
async updatePreferences(userId: string, prefs: Partial<PushPreferences>) {
await fetch(`/api/push/preferences/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs),
});
}
isInQuietHours(prefs: PushPreferences, category: string): boolean {
const cat = prefs.categories[category];
if (!cat?.quietHoursStart || !cat?.quietHoursEnd) return false;
const now = new Date();
const hours = now.getHours();
const start = parseInt(cat.quietHoursStart);
const end = parseInt(cat.quietHoursEnd);
if (start <= end) return hours >= start && hours < end;
return hours >= start || hours < end;
}
}
よくある落とし穴
落とし穴1:VAPIDキー形式エラー
// ❌ 間違い:Base64文字列をそのまま使用
applicationServerKey: 'BPxxx...'
// ✅ 正しい:Uint8Arrayに変換
applicationServerKey: urlBase64ToUint8Array('BPxxx...')
落とし穴2:期限切れサブスクリプションの未処理
// ❌ 間違い:410 Goneエラーを無視
await webpush.sendNotification(sub, payload);
// ✅ 正しい:期限切れサブスクリプションをクリーンアップ
try {
await webpush.sendNotification(sub, payload);
} catch (err) {
if (err.statusCode === 410) {
await db.removeSubscription(sub.endpoint);
}
}
落とし穴3:Payloadなしのプッシュ
// ❌ 間違い:サーバーがPayloadを送信せず、SWがデータにアクセスできない
// 汎用通知しか表示できない
// ✅ 正しい:暗号化Payloadを送信
const payload = JSON.stringify({ title: '新着メッセージ', body: '内容', url: '/inbox' });
await webpush.sendNotification(subscription, payload);
落とし穴4:通知クリックでウィンドウがフォーカスされない
// ❌ 間違い:常に新しいウィンドウを開く
self.clients.openWindow(url);
// ✅ 正しい:既存ウィンドウを優先的にフォーカス
const clients = await self.clients.matchAll({ type: 'window' });
const client = clients.find(c => c.url.includes(origin));
if (client) client.focus();
else self.clients.openWindow(url);
落とし穴5:iOS Safari互換性
// ❌ 間違い:全ブラウザの動作が同じと仮定
// Safariはactions、imageなどの通知オプションをサポートしていない
// ✅ 正しい:機能検出後にグレースフルデグラデーション
const supportsActions = 'actions' in Notification.prototype;
const options: NotificationOptions = {
body: data.body,
...(supportsActions ? { actions: data.actions } : {}),
};
エラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決方法 |
|---|---|---|---|
| 1 | Registration failed - permission denied |
ユーザーが通知権限を拒否 | 設定で有効化するようガイド |
| 2 | Push subscription expired |
サブスクリプション期限切れ | 再サブスクライブしてサーバー記録を更新 |
| 3 | 410 Gone |
プッシュサービスがサブスクリプションを解除 | データベースからサブスクリプションを削除 |
| 4 | 413 Payload too large |
プッシュデータが4KBを超過 | Payloadサイズを削減 |
| 5 | VAPID authentication failed |
VAPIDキーの不一致 | 公開鍵/秘密鍵ペアを確認 |
| 6 | Service Worker not registered |
SW登録失敗 | SWファイルパスとHTTPSを確認 |
| 7 | Push event not received |
SWがアクティブでない | SWがactivate済みでclientsをclaimしていることを確認 |
| 8 | Notification not shown |
システムが通知をブロック | システムレベルの通知権限を確認 |
| 9 | Background Sync not triggered |
ブラウザが非サポート | タイマーリトライにフォールバック |
| 10 | Safari push error |
SafariプッシュAPIの差異 | Safari専用プッシュAPIを使用 |
高度な最適化
- プッシュグルーピング:同じタグの通知を自動置換、通知のスタックを防止
- サイレントプッシュ:
silent: trueでSWのみ起動してデータ更新、通知非表示 - プッシュ分析:到達率、開封率、コンバージョン率を追跡
- A/Bテスト:異なる通知文案とタイミングの効果比較
- Web Pushゲートウェイ:自己構築プッシュゲートウェイでマルチブラウザプッシュサービスを統一管理
比較分析
| 次元 | Web Push | SSE | WebSocket | ポーリング |
|---|---|---|---|---|
| オフラインプッシュ | ✅ | ❌ | ❌ | ❌ |
| ページ閉鎖時プッシュ | ✅ | ❌ | ❌ | ❌ |
| リアルタイム性 | 秒単位 | ミリ秒 | ミリ秒 | 間隔に依存 |
| サーバーリソース | 低 | 中 | 高 | 高 |
| ブラウザサポート | 主要 | 主要 | 主要 | 全て |
| 双方向通信 | ❌ | ❌ | ✅ | ✅ |
まとめ:Service Workerプッシュ通知により、Webアプリはネイティブアプリと同等のリアルタイム通知能力を獲得しました。VAPID認証+Push API+Background Syncの三位一体で、サブスクリプション管理からオフラインメッセージキューまでの完全なプッシュパイプラインを実現。2026年のSafari Push APIサポートにより、ついにクロスブラウザWebプッシュカバレッジが実現し、プッシュ頻度制御とユーザー設定管理が本番環境の鍵となります。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Hash計算:/ja/encode/hash
- cURL→コード変換:/ja/dev/curl-to-code
ブラウザローカルツールを無料で試す →
#Service Worker推送#Web Push#离线通知#PWA推送#2026#前端工程