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

  1. Browser compatibility: Safari push API differs significantly from Chrome
  2. Subscription management: Subscriptions become invalid when users change devices
  3. Push encryption: Payload encryption is complex, key management is cumbersome
  4. Offline scenarios: Handling push messages when users are offline
  5. 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

  1. Push Grouping: Same-tag notifications auto-replace to avoid stacking
  2. Silent Push: silent: true only wakes SW to update data without showing notification
  3. Push Analytics: Track delivery rate, open rate, and conversion rate
  4. A/B Testing: Compare effectiveness of different notification copy and timing
  5. 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.


Try these browser-local tools — no sign-up required →

#Service Worker推送#Web Push#离线通知#PWA推送#2026#前端工程