最終更新:
Web Push通知実装ガイド: Service WorkerとPush APIの活用
Web Push通知とは?
Web Push通知は、ブラウザを閉じていてもユーザーにメッセージを届けられる強力な機能です。PWA(Progressive Web Apps)の中核技術の一つで、ネイティブアプリのようなユーザー体験を実現します。
Web Pushの仕組み
[Webアプリ] → [Service Worker] → [Push Service] → [ユーザーのデバイス]
↓ ↓ ↑
[あなたのサーバー] ←------ 通知送信 ---┘
主要コンポーネント:
- Service Worker - バックグラウンドで動作し、Push イベントを受信
- Push API - サーバーからの通知を受け取る
- Notification API - 通知を表示する
- Push Service - ブラウザベンダーが提供する配信サービス(FCM、APNSなど)
対応ブラウザ
2026年現在の対応状況:
- ✅ Chrome/Edge 42+
- ✅ Firefox 44+
- ✅ Opera 42+
- ✅ Safari 16+ (macOS 13+、iOS 16.4+)
- ❌ IE(サポート終了)
実装の全体フロー
フロントエンド(ユーザーのブラウザ)
- Service Workerを登録
- 通知の許可をリクエスト
- Push購読を作成
- サーバーに購読情報を送信
バックエンド(あなたのサーバー)
- 購読情報をデータベースに保存
- 通知を送信したいタイミングで、Push Serviceに配信リクエスト
- Push ServiceがService Workerにイベントを配信
- Service Workerが通知を表示
Service Workerのセットアップ
Service Workerの登録
// main.ts
async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator)) {
console.warn('Service Worker is not supported');
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('Service Worker registered:', registration.scope);
// 更新チェック
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('New Service Worker found:', newWorker);
});
return registration;
} catch (error) {
console.error('Service Worker registration failed:', error);
return null;
}
}
// アプリ起動時に実行
registerServiceWorker();
Service Workerファイル
// sw.js
const CACHE_NAME = 'my-app-v1';
// Service Workerのインストール
self.addEventListener('install', (event) => {
console.log('Service Worker installing...');
self.skipWaiting(); // すぐにアクティブ化
});
// Service Workerのアクティブ化
self.addEventListener('activate', (event) => {
console.log('Service Worker activated');
event.waitUntil(clients.claim()); // すべてのクライアントを制御
});
// Pushイベントの受信
self.addEventListener('push', (event) => {
console.log('Push event received:', event);
const data = event.data ? event.data.json() : {};
const title = data.title || 'New Notification';
const options = {
body: data.body || 'You have a new message',
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
data: data.url || '/',
tag: data.tag || 'default',
requireInteraction: data.requireInteraction || false,
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// 通知クリック時の処理
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event);
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data || '/')
);
});
Push購読の作成
VAPID鍵の生成
VAPID(Voluntary Application Server Identification)は、あなたのサーバーを識別するための鍵ペアです。
# web-push ライブラリをインストール
npm install web-push
# 鍵ペアを生成
npx web-push generate-vapid-keys
出力例:
Public Key:
BEl62iUY...(省略)
Private Key:
wpIxJ3mE...(省略)
この鍵を安全に保存してください。
フロントエンドで購読作成
// push-manager.ts
interface PushSubscriptionData {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export class PushManager {
private vapidPublicKey: string;
constructor(vapidPublicKey: string) {
this.vapidPublicKey = vapidPublicKey;
}
// 通知許可のリクエスト
async requestPermission(): Promise<NotificationPermission> {
if (!('Notification' in window)) {
throw new Error('Notification API not supported');
}
const permission = await Notification.requestPermission();
console.log('Notification permission:', permission);
return permission;
}
// Push購読の作成
async subscribe(
registration: ServiceWorkerRegistration
): Promise<PushSubscriptionData> {
// 既存の購読をチェック
let subscription = await registration.pushManager.getSubscription();
if (subscription) {
console.log('Already subscribed:', subscription);
} else {
// 新規購読
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // 必須: 通知は常にユーザーに表示
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
});
console.log('New subscription created:', subscription);
}
return this.subscriptionToJSON(subscription);
}
// 購読の解除
async unsubscribe(registration: ServiceWorkerRegistration): Promise<boolean> {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
return await subscription.unsubscribe();
}
return false;
}
// Base64 URLをUint8Arrayに変換
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// PushSubscriptionをJSON形式に変換
private subscriptionToJSON(subscription: PushSubscription): PushSubscriptionData {
const json = subscription.toJSON();
return {
endpoint: json.endpoint!,
keys: {
p256dh: json.keys!.p256dh,
auth: json.keys!.auth,
},
};
}
}
使用例
// app.ts
const VAPID_PUBLIC_KEY = 'BEl62iUY...'; // あなたの公開鍵
async function setupPushNotification() {
// Service Workerを登録
const registration = await registerServiceWorker();
if (!registration) return;
// 通知マネージャーを作成
const pushManager = new PushManager(VAPID_PUBLIC_KEY);
// 許可をリクエスト
const permission = await pushManager.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission denied');
return;
}
// 購読を作成
const subscription = await pushManager.subscribe(registration);
// サーバーに送信
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscription,
userId: getCurrentUserId(),
}),
});
console.log('Push notification setup complete!');
}
// ボタンクリックで購読
document.getElementById('enable-push')?.addEventListener('click', () => {
setupPushNotification();
});
バックエンド実装(Node.js)
サーバーサイドのセットアップ
// server.ts
import express from 'express';
import webpush from 'web-push';
const app = express();
app.use(express.json());
// VAPID鍵を設定
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
// 購読情報をメモリに保存(本番環境ではDBを使用)
const subscriptions = new Map<string, webpush.PushSubscription>();
// 購読の保存
app.post('/api/push/subscribe', (req, res) => {
const { subscription, userId } = req.body;
subscriptions.set(userId, subscription);
console.log(`User ${userId} subscribed`);
res.status(201).json({ message: 'Subscribed successfully' });
});
// 購読の削除
app.post('/api/push/unsubscribe', (req, res) => {
const { userId } = req.body;
subscriptions.delete(userId);
res.json({ message: 'Unsubscribed successfully' });
});
// 通知の送信
app.post('/api/push/send', async (req, res) => {
const { userId, title, body, url } = req.body;
const subscription = subscriptions.get(userId);
if (!subscription) {
return res.status(404).json({ error: 'Subscription not found' });
}
const payload = JSON.stringify({
title,
body,
url,
icon: '/icon-192.png',
badge: '/badge-72.png',
});
try {
await webpush.sendNotification(subscription, payload);
res.json({ message: 'Notification sent' });
} catch (error) {
console.error('Error sending notification:', error);
// 410 Goneエラー = 購読が無効
if ((error as any).statusCode === 410) {
subscriptions.delete(userId);
}
res.status(500).json({ error: 'Failed to send notification' });
}
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
データベースでの購読管理(Prisma)
// schema.prisma
model PushSubscription {
id String @id @default(cuid())
userId String
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// push-service.ts
import { PrismaClient } from '@prisma/client';
import webpush from 'web-push';
const prisma = new PrismaClient();
export class PushService {
// 購読の保存
async saveSubscription(
userId: string,
subscription: {
endpoint: string;
keys: { p256dh: string; auth: string };
}
) {
return await prisma.pushSubscription.upsert({
where: { endpoint: subscription.endpoint },
create: {
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
update: {
userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
});
}
// 特定ユーザーに送信
async sendToUser(userId: string, payload: any) {
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId },
});
const results = await Promise.allSettled(
subscriptions.map(sub =>
webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
JSON.stringify(payload)
)
)
);
// 失敗した購読を削除
const failedSubscriptions = results
.map((result, index) => {
if (result.status === 'rejected' &&
(result.reason as any).statusCode === 410) {
return subscriptions[index].id;
}
})
.filter(Boolean);
if (failedSubscriptions.length > 0) {
await prisma.pushSubscription.deleteMany({
where: { id: { in: failedSubscriptions as string[] } },
});
}
return {
sent: results.filter(r => r.status === 'fulfilled').length,
failed: results.filter(r => r.status === 'rejected').length,
};
}
// 全ユーザーに送信
async sendToAll(payload: any) {
const subscriptions = await prisma.pushSubscription.findMany();
const results = await Promise.allSettled(
subscriptions.map(sub =>
webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
JSON.stringify(payload)
)
)
);
return {
sent: results.filter(r => r.status === 'fulfilled').length,
failed: results.filter(r => r.status === 'rejected').length,
};
}
}
高度な通知機能
通知のカスタマイズ
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
image: data.image, // 大きな画像
vibrate: [200, 100, 200], // バイブレーションパターン
data: {
url: data.url,
timestamp: Date.now(),
},
tag: data.tag || 'default', // 同じtagは上書き
renotify: true, // 再通知
requireInteraction: data.urgent, // ユーザーが閉じるまで表示
actions: [
{
action: 'view',
title: '開く',
icon: '/icons/open.png',
},
{
action: 'dismiss',
title: '閉じる',
icon: '/icons/close.png',
},
],
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
通知アクションの処理
// sw.js
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'view') {
// "開く" ボタン
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
} else if (event.action === 'dismiss') {
// "閉じる" ボタン
console.log('Notification dismissed');
} else {
// 通知本体をクリック
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
サイレント通知(バックグラウンド同期)
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
if (data.silent) {
// 通知を表示せず、データだけ処理
event.waitUntil(
syncData(data).then(() => {
// クライアントに通知
return self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'SILENT_PUSH',
data: data,
});
});
});
})
);
} else {
// 通常の通知
event.waitUntil(
self.registration.showNotification(data.title, data.options)
);
}
});
async function syncData(data) {
// データベースやキャッシュを更新
const cache = await caches.open('data-cache');
await cache.put(data.url, new Response(JSON.stringify(data.content)));
}
実践的なユースケース
チャットアプリの新着メッセージ通知
// server.ts
interface ChatMessage {
id: string;
from: string;
content: string;
roomId: string;
}
async function notifyNewMessage(message: ChatMessage, recipientUserId: string) {
const pushService = new PushService();
await pushService.sendToUser(recipientUserId, {
title: `New message from ${message.from}`,
body: message.content.substring(0, 100),
icon: `/avatars/${message.from}.png`,
url: `/chat/${message.roomId}`,
tag: `chat-${message.roomId}`, // 同じルームの通知は上書き
requireInteraction: false,
actions: [
{ action: 'reply', title: 'Reply' },
{ action: 'view', title: 'View' },
],
});
}
ECサイトの在庫復活通知
// product-service.ts
async function notifyProductAvailable(productId: string) {
// このプロダクトを待機リストに入れているユーザーを取得
const waitingUsers = await prisma.productWaitlist.findMany({
where: { productId },
include: { user: true },
});
const pushService = new PushService();
for (const { user } of waitingUsers) {
await pushService.sendToUser(user.id, {
title: 'Product back in stock!',
body: 'The product you wanted is now available',
icon: '/product-icon.png',
image: `/products/${productId}/image.jpg`,
url: `/products/${productId}`,
tag: `product-${productId}`,
requireInteraction: true,
actions: [
{ action: 'buy', title: 'Buy Now' },
{ action: 'view', title: 'View' },
],
});
}
}
パフォーマンスとベストプラクティス
1. 通知の頻度制限
// rate-limiter.ts
class NotificationRateLimiter {
private limits = new Map<string, number[]>();
private maxPerHour = 5;
canSend(userId: string): boolean {
const now = Date.now();
const userLimits = this.limits.get(userId) || [];
// 1時間以内の通知をフィルタ
const recentNotifications = userLimits.filter(
time => now - time < 60 * 60 * 1000
);
if (recentNotifications.length >= this.maxPerHour) {
return false;
}
recentNotifications.push(now);
this.limits.set(userId, recentNotifications);
return true;
}
}
2. バッチ送信
// batch-sender.ts
async function sendBatchNotifications(
notifications: Array<{ userId: string; payload: any }>
) {
// 100件ずつバッチ処理
const batchSize = 100;
const pushService = new PushService();
for (let i = 0; i < notifications.length; i += batchSize) {
const batch = notifications.slice(i, i + batchSize);
await Promise.allSettled(
batch.map(({ userId, payload }) =>
pushService.sendToUser(userId, payload)
)
);
// レート制限を避けるため、少し待つ
await new Promise(resolve => setTimeout(resolve, 100));
}
}
3. 通知の優先度
interface NotificationPriority {
urgent: boolean;
ttl: number; // Time To Live (秒)
}
async function sendPriorityNotification(
userId: string,
payload: any,
priority: NotificationPriority
) {
const subscription = await getSubscription(userId);
await webpush.sendNotification(
subscription,
JSON.stringify(payload),
{
urgency: priority.urgent ? 'high' : 'normal',
TTL: priority.ttl,
}
);
}
セキュリティ対策
1. エンドポイントの検証
function isValidPushEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
// 信頼できるPush Serviceのみ許可
const trustedDomains = [
'fcm.googleapis.com',
'updates.push.services.mozilla.com',
'web.push.apple.com',
];
return trustedDomains.some(domain => url.hostname.endsWith(domain));
} catch {
return false;
}
}
2. ペイロードの暗号化
web-pushライブラリが自動的に暗号化しますが、追加のセキュリティ層を実装できます。
import crypto from 'crypto';
function encryptSensitiveData(data: any, key: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), iv);
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
まとめ
Web Push通知は、ユーザーエンゲージメントを高める強力なツールです。
実装のポイント:
- Service Worker - バックグラウンド処理の要
- VAPID認証 - サーバー認証で安全な通信
- 購読管理 - データベースで永続化
- 通知戦略 - 頻度制限とパーソナライゼーション
- UX最適化 - 適切なタイミングで許可リクエスト
ベストプラクティス:
- 通知の送りすぎに注意(1日5件まで)
- ユーザーに価値を提供する通知のみ
- 簡単に解除できる仕組み
- パフォーマンスとバッテリー消費に配慮
Web Push通知を適切に実装することで、ネイティブアプリと遜色ないユーザー体験を提供できます。