Service Worker Push Notification in Practice: 5 Patterns for Web Push & Offline Alert Systems
Service Worker Push: Real-Time Notification for Web Apps
Mobile apps have push notifications—can web apps only rely on polling? Service Worker + Web Push API enables web apps to receive server-pushed real-time notifications even when users close the page. In 2026, the Push API is fully supported in Chrome/Firefox/Edge/Safari, and the Background Sync API enables offline message queuing—web push capabilities now rival native apps.
This article walks through 5 core patterns, covering the full pipeline from subscription management → push sending → offline notifications → background sync → notification interaction.
Core Concepts
| Concept | Description |
|---|---|
| Service Worker | Browser background script that intercepts network requests |
| Push API | API for server-to-client push messaging |
| Notification API | Browser native notification display API |
| VAPID | Push authentication protocol with self-hosted keys |
| Subscription | Client push subscription object with endpoint and keys |
| Background Sync | API for automatic retry after network recovery |
| Push Event | Push event received by Service Worker |
| Notification Event | User click/close notification event |
Problem Analysis: 5 Challenges in Service Worker Push
- Browser compatibility: Safari push API differs significantly from Chrome
- Subscription management: Subscriptions become invalid when users change devices
- Push encryption: Payload encryption is complex, key management is cumbersome
- Offline scenarios: Handling push messages when users are offline
- Notification fatigue: Excessive push frequency causes users to revoke permission
Step-by-Step: 5 Service Worker Push Patterns
Pattern 1: VAPID Keys & Subscription Management
// Generate VAPID keys
// 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
);
// Client-side subscription management
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)));
}
}
Pattern 2: Server-Side Push Sending
// 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 }),
});
}
}
Pattern 3: Service Worker Push Event Handling
// 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('New Message', {
body: 'You have received a new message',
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' },
})
);
});
Pattern 4: Background Sync Offline Message Queue
// Client-side sync registration
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 handling
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');
}
}
Pattern 5: Push Frequency Control & User Preferences
// Push frequency controller
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;
}
}
// User preference management 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;
}
}
Pitfall Guide
Pitfall 1: VAPID Key Format Error
// ❌ Wrong: Using raw Base64 string
applicationServerKey: 'BPxxx...'
// ✅ Correct: Convert to Uint8Array
applicationServerKey: urlBase64ToUint8Array('BPxxx...')
Pitfall 2: Not Handling Expired Subscriptions
// ❌ Wrong: Ignoring 410 Gone errors
await webpush.sendNotification(sub, payload);
// ✅ Correct: Clean up expired subscriptions
try {
await webpush.sendNotification(sub, payload);
} catch (err) {
if (err.statusCode === 410) {
await db.removeSubscription(sub.endpoint);
}
}
Pitfall 3: Push Without Payload
// ❌ Wrong: Server sends no payload, SW can't access data
// Can only show generic notifications
// ✅ Correct: Send encrypted payload
const payload = JSON.stringify({ title: 'New Message', body: 'Content', url: '/inbox' });
await webpush.sendNotification(subscription, payload);
Pitfall 4: Notification Click Not Focusing Window
// ❌ Wrong: Always opens new window
self.clients.openWindow(url);
// ✅ Correct: Prefer focusing existing window
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);
Pitfall 5: iOS Safari Compatibility
// ❌ Wrong: Assuming all browsers behave identically
// Safari doesn't support actions, image, etc. in notifications
// ✅ Correct: Detect capabilities and degrade gracefully
const supportsActions = 'actions' in Notification.prototype;
const options: NotificationOptions = {
body: data.body,
...(supportsActions ? { actions: data.actions } : {}),
};
Error Troubleshooting
| # | Error Message | Cause | Solution |
|---|---|---|---|
| 1 | Registration failed - permission denied |
User denied notification permission | Guide user to enable in settings |
| 2 | Push subscription expired |
Subscription expired | Re-subscribe and update server records |
| 3 | 410 Gone |
Push service unsubscribed | Remove subscription from database |
| 4 | 413 Payload too large |
Push data exceeds 4KB | Reduce payload size |
| 5 | VAPID authentication failed |
VAPID key mismatch | Verify public/private key pair |
| 6 | Service Worker not registered |
SW registration failed | Check SW file path and HTTPS |
| 7 | Push event not received |
SW not activated | Ensure SW is activated and claims clients |
| 8 | Notification not shown |
Notifications blocked by system | Check system-level notification permissions |
| 9 | Background Sync not triggered |
Browser doesn't support | Fallback to timed retry |
| 10 | Safari push error |
Safari push API differences | Use Safari-specific push API |
Advanced Optimization
- Push Grouping: Same-tag notifications auto-replace to avoid stacking
- Silent Push:
silent: trueonly wakes SW to update data without showing notification - Push Analytics: Track delivery rate, open rate, and conversion rate
- A/B Testing: Compare effectiveness of different notification copy and timing
- Web Push Gateway: Self-hosted push gateway for unified multi-browser push service management
Comparison
| Dimension | Web Push | SSE | WebSocket | Polling |
|---|---|---|---|---|
| Offline Push | ✅ | ❌ | ❌ | ❌ |
| Push with Page Closed | ✅ | ❌ | ❌ | ❌ |
| Real-time | Seconds | Milliseconds | Milliseconds | Depends on interval |
| Server Resources | Low | Medium | High | High |
| Browser Support | Mainstream | Mainstream | Mainstream | All |
| Bidirectional | ❌ | ❌ | ✅ | ✅ |
Summary: Service Worker push notifications give web apps the same real-time notification capabilities as native apps. VAPID authentication + Push API + Background Sync form a complete push pipeline from subscription management to offline message queuing. In 2026, Safari's Push API support finally enables cross-browser web push coverage, and push frequency control with user preference management are critical for production environments.
Recommended Online Tools
- JSON Formatter: /en/json/format
- Hash Calculator: /en/encode/hash
- cURL to Code: /en/dev/curl-to-code
Try these browser-local tools — no sign-up required →