MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Push Notifications: VAPID, Service Workers, and Best Practices

Implement web push: VAPID keys, subscription management, notification design, and delivery.

Web PushNotificationsService WorkersFrontend

By MinhVo

Introduction

Web push notifications represent one of the most powerful re-engagement tools available to web developers today. Unlike email marketing, push notifications deliver instant, clickable messages directly to a user's desktop or mobile device, even when the browser tab is closed. The technology relies on service workers running in the background, VAPID (Voluntary Application Server Identification) keys for secure authentication, and push services maintained by browser vendors.

Web push notification architecture diagram

The ecosystem has matured significantly since Chrome first introduced web push in 2015. Today, all major browsers support the Push API and Notification API, making it possible to reach users across Chrome, Firefox, Edge, and Safari. E-commerce platforms report 15-25% cart recovery rates through push notifications, while news organizations achieve open rates 4-8x higher than email campaigns.

However, implementing web push correctly requires understanding cryptographic key exchange, service worker lifecycle management, and subscription state synchronization. This guide covers every aspect from VAPID key generation through production-grade notification delivery at scale.

Understanding Web Push: Core Concepts

Web push notifications operate through a three-party architecture involving the application server, the push service (operated by the browser vendor), and the client browser running a service worker. When a user subscribes, the browser contacts the push service to generate a unique endpoint URL and cryptographic keys. The application server then uses these credentials to send encrypted messages through the push service.

VAPID authentication eliminates the need for browser-specific API keys. Instead of registering separate keys with GCM (Chrome), Mozilla Push Service (Firefox), and other vendors, developers generate a single P-256 elliptic curve key pair. The application server signs a JWT claim with its private key on each push request, and the push service verifies it using the registered public key.

The Push API specification defines two delivery guarantees: visible and silent. Visible notifications require a notification to be displayed when a push message arrives, preventing abuse of background processing. Silent pushes (delivery without display) are restricted to user-visible-only subscriptions, ensuring ethical notification practices.

Service worker lifecycle

Subscription Lifecycle Management

Subscription management is the most error-prone aspect of web push implementations. Subscriptions can expire, be revoked by the user, or become invalid when browser data is cleared. A production system must track subscription state, detect delivery failures (HTTP 410 Gone), and implement automatic re-subscription prompts when active subscriptions drop below threshold.

Architecture and Design Patterns

Component 1: VAPID Key Management

VAPID keys should be generated once and stored securely in environment variables or a secrets manager. The private key must never be exposed to the client. In multi-server deployments, all application server instances share the same VAPID credentials to maintain subscription validity.

Component 2: Subscription Store

The subscription store maintains the mapping between users and their push endpoints. Each subscription record contains the endpoint URL, P-256DH public key, and auth secret. The store must support efficient lookups by user ID and batch queries for broadcast notifications.

Component 3: Push Delivery Pipeline

The delivery pipeline handles encryption, batching, retry logic, and delivery status tracking. Modern implementations use message queues (Redis, RabbitMQ) to decouple notification generation from delivery, enabling rate limiting and priority queuing.

Component 4: Service Worker Event Handlers

Service workers process three critical events: push for receiving messages, notificationclick for handling user interaction, and notificationclose for tracking dismissals. Each event handler must complete within the browser's time budget (typically 30 seconds).

Step-by-Step Implementation

Setting up web push requires server-side key management, client-side subscription logic, and service worker event handling. The following implementation uses the web-push library for Node.js.

Server-Side: VAPID Configuration

import webPush from 'web-push';
import crypto from 'crypto';
 
// Generate VAPID keys (do this once, store securely)
const vapidKeys = webPush.generateVAPIDKeys();
 
// Configure web-push with your VAPID details
webPush.setVapidDetails(
  'mailto:admin@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);
 
// Subscription storage schema
interface PushSubscription {
  id: string;
  userId: string;
  endpoint: string;
  keys: {
    p256dh: string;
    auth: string;
  };
  createdAt: Date;
  expirationTime: number | null;
  isActive: boolean;
}

Client-Side: Service Worker Registration and Subscription

// Register service worker and subscribe to push
async function subscribeToPush(): Promise<void> {
  const registration = await navigator.serviceWorker.ready;
 
  // Check existing subscription
  let subscription = await registration.pushManager.getSubscription();
 
  if (!subscription) {
    // Request notification permission
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') {
      throw new Error('Notification permission denied');
    }
 
    // Create new subscription
    subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
      ),
    });
  }
 
  // Send subscription to server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      endpoint: subscription.endpoint,
      keys: {
        p256dh: arrayBufferToBase64(subscription.getKey('p256dh')!),
        auth: arrayBufferToBase64(subscription.getKey('auth')!),
      },
    }),
  });
}
 
function 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((c) => c.charCodeAt(0)));
}
 
function arrayBufferToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  bytes.forEach((b) => (binary += String.fromCharCode(b)));
  return btoa(binary);
}

Service Worker: Push and Click Handlers

// sw.ts — Service worker
self.addEventListener('push', (event: PushEvent) => {
  const data = event.data?.json() ?? {};
 
  const options: NotificationOptions = {
    body: data.body,
    icon: data.icon || '/icons/default-icon.png',
    badge: data.badge || '/icons/badge.png',
    image: data.image,
    data: { url: data.url },
    tag: data.tag,
    renotify: data.renotify ?? false,
    requireInteraction: data.requireInteraction ?? false,
    actions: data.actions ?? [],
    vibrate: [200, 100, 200],
  };
 
  event.waitUntil(
    self.registration.showNotification(data.title ?? 'Notification', options)
  );
});
 
self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();
 
  const targetUrl = event.notification.data?.url || '/';
 
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
      // Focus existing window if open
      for (const client of windowClients) {
        if (client.url.includes(self.location.origin) && 'focus' in client) {
          client.navigate(targetUrl);
          return client.focus();
        }
      }
      // Open new window
      return clients.openWindow(targetUrl);
    })
  );
});

Server-Side: Sending Notifications

import webPush from 'web-push';
import { db } from './database';
 
interface NotificationPayload {
  title: string;
  body: string;
  icon?: string;
  badge?: string;
  image?: string;
  url?: string;
  tag?: string;
  actions?: Array<{ action: string; title: string }>;
}
 
async function sendPushToUser(userId: string, payload: NotificationPayload): Promise<void> {
  const subscriptions = await db.pushSubscription.findMany({
    where: { userId, isActive: true },
  });
 
  const results = await Promise.allSettled(
    subscriptions.map((sub) => sendPushNotification(sub, payload))
  );
 
  // Handle expired subscriptions
  results.forEach((result, index) => {
    if (result.status === 'rejected' && result.reason?.statusCode === 410) {
      db.pushSubscription.update({
        where: { id: subscriptions[index].id },
        data: { isActive: false },
      });
    }
  });
}
 
async function sendPushNotification(
  subscription: PushSubscription,
  payload: NotificationPayload
): Promise<webPush.SendResult> {
  return webPush.sendNotification(
    {
      endpoint: subscription.endpoint,
      keys: { p256dh: subscription.keys.p256dh, auth: subscription.keys.auth },
    },
    JSON.stringify(payload),
    {
      TTL: 86400, // 24 hours
      urgency: 'high',
      topic: 'notification',
    }
  );
}

Push delivery pipeline

Real-World Use Cases and Case Studies

Use Case 1: E-Commerce Cart Abandoned Recovery

Abandoned cart push notifications recover 15-25% of lost sales when timed correctly. The optimal strategy sends a first reminder within 30 minutes of abandonment, a second with a 5% discount at 2 hours, and a final 10% discount at 24 hours. Each notification includes the cart item image and a direct deep link to the checkout page, reducing friction to a single tap.

Use Case 2: Breaking News Alerts

News publishers like The Washington Post use topic-based push subscriptions. Users subscribe to categories (politics, sports, technology) and receive only relevant alerts. The system limits notifications to 5 per day per category, preventing fatigue while maintaining urgency for breaking stories.

Use Case 3: Real-Time Collaboration Updates

Project management tools like Linear and Notion send push notifications for task assignments, comments, and status changes. These notifications include inline reply actions, allowing users to respond without opening the app, dramatically reducing context-switching overhead.

Best Practices for Production

  1. Request permission contextually: Don't prompt immediately on page load. Wait for a meaningful user action (e.g., clicking "Enable notifications" on a settings page). Contextual prompts achieve 60-80% acceptance rates versus 10-15% for immediate prompts.

  2. Implement VAPID key rotation: Rotate your VAPID keys periodically. Maintain backward compatibility by accepting both old and new keys during transition periods. Use environment-specific keys for staging and production.

  3. Design rich notifications with images: Include hero images, action buttons, and badges in every notification. Rich notifications see 25-56% higher engagement than text-only variants. Optimize images to under 200KB for fast rendering.

  4. Respect notification quotas: Browsers enforce rate limits on push delivery. Chrome limits silent pushes and will throttle subscriptions that receive too many messages. Implement server-side rate limiting per user (e.g., max 5 notifications per hour).

  5. Track delivery and engagement: Implement webhook endpoints for delivery receipts and track click-through rates per notification type. Use A/B testing to optimize message timing, content, and action buttons.

  6. Handle subscription expiration gracefully: Monitor expirationTime on subscriptions and prompt re-subscription 48 hours before expiry. Implement background sync to retry failed deliveries when connectivity returns.

  7. Implement geographic and timezone-aware delivery: Schedule notifications based on the user's local timezone. Never send promotional notifications between 10 PM and 8 AM local time unless the content is time-sensitive.

  8. Use web-push libraries, not raw HTTP: Libraries like web-push (Node.js), pywebpush (Python), and webpush (Go) handle ECDH encryption, VAPID JWT signing, and retry logic correctly. Implementing encryption from scratch is error-prone and has caused real security vulnerabilities.

Common Pitfalls and Solutions

PitfallImpactSolution
Requesting permission on first visit85%+ denial rate; blocks future promptsWait for engagement signal, show value proposition first
Storing subscriptions without userId associationCannot target notifications per userAlways link subscriptions to authenticated user accounts
Ignoring HTTP 410 responsesDead subscriptions accumulate, wasting resourcesMark inactive on 410, clean up monthly via cron
Sending identical content to all usersLow engagement, high unsubscription rateSegment by user behavior, personalize with dynamic content
Not testing on SafariBroken notifications for 15-20% of desktop usersTest APNs VAPID flow separately; Safari uses different push service
Exposing VAPID private key in client codeAny server can send notifications to your subscribersStore private key exclusively in server environment variables

Performance Optimization

Push notification delivery at scale requires careful infrastructure design. A single-threaded Node.js process can deliver approximately 500 notifications per second through the web-push library. For applications with millions of subscribers, implement a distributed delivery pipeline using message queues.

// Redis-based batch delivery pipeline
import Bull from 'bullmq';
 
const pushQueue = new Bull('push-notifications', {
  connection: { host: 'redis', port: 6379 },
});
 
// Enqueue broadcast notification
async function broadcastNotification(payload: NotificationPayload): Promise<void> {
  const batchSize = 500;
  let cursor = 0;
 
  while (true) {
    const subscriptions = await db.pushSubscription.findMany({
      where: { isActive: true },
      skip: cursor,
      take: batchSize,
    });
 
    if (subscriptions.length === 0) break;
 
    await pushQueue.addBulk(
      subscriptions.map((sub) => ({
        name: 'deliver',
        data: { subscription: sub, payload },
        opts: { attempts: 3, backoff: { type: 'exponential', delay: 5000 } },
      }))
    );
 
    cursor += batchSize;
  }
}
 
// Worker processes delivery jobs
const worker = new Bull.Worker('push-notifications', async (job) => {
  const { subscription, payload } = job.data;
  try {
    await sendPushNotification(subscription, payload);
  } catch (error) {
    if (error.statusCode === 410) {
      await db.pushSubscription.update({
        where: { id: subscription.id },
        data: { isActive: false },
      });
    }
    throw error; // Let Bull retry
  }
});

This pattern scales horizontally by adding more worker processes. A cluster of 10 workers can deliver 5,000+ notifications per second with automatic retry and dead-letter queuing for permanent failures.

Comparison with Alternatives

FeatureWeb PushEmailSMSIn-App Messaging
Delivery when browser closedYesN/AN/ANo
Requires app installationNoNoNoYes
Cost per messageFree$0.001-0.05$0.01-0.05Free
Average open rate15-25%20-30%98%40-60%
Rich media supportImages, actionsFull HTMLText onlyFull rich media
User friction to subscribeLowMediumHighMedium
Cross-device syncBrowser-basedYesPer-devicePer-app
Offline queuingYes (service worker)NoNoDepends

Web push excels for real-time alerts and re-engagement without requiring app installation. Email remains superior for detailed content and longer-form communication. SMS provides the highest open rates but at significant cost and user friction.

Advanced Patterns and Techniques

Notification Personalization Engine

// Personalize notifications based on user behavior
interface UserBehaviorProfile {
  activeHours: { start: number; end: number };
  preferredCategories: string[];
  engagementScore: number;
  lastActiveAt: Date;
}
 
async function personalizeNotification(
  userId: string,
  basePayload: NotificationPayload
): Promise<NotificationPayload> {
  const profile = await getUserBehaviorProfile(userId);
 
  // Adjust timing based on active hours
  const now = new Date();
  const userHour = now.getUTCHours() + profile.timezoneOffset;
  if (userHour < profile.activeHours.start || userHour > profile.activeHours.end) {
    await scheduleNotification(userId, basePayload, nextActiveTime(profile));
    return basePayload;
  }
 
  // Adjust urgency based on engagement score
  if (profile.engagementScore < 0.3) {
    // Low engagement: use stronger call-to-action
    basePayload.body = `⭐ ${basePayload.body}`;
    basePayload.requireInteraction = true;
  }
 
  return basePayload;
}

Notification Grouping and Stacking

// Group related notifications to prevent spam
self.addEventListener('push', (event: PushEvent) => {
  const data = event.data?.json();
 
  event.waitUntil(
    self.registration.getNotifications({ tag: data.tag }).then((existing) => {
      if (existing.length > 0) {
        // Stack: show count in the latest notification
        const count = existing.length + 1;
        data.body = `${data.body} (+${count - 1} more)`;
      }
 
      return self.registration.showNotification(data.title, {
        body: data.body,
        tag: data.tag,
        renotify: true,
        data: data,
      });
    })
  );
});

Testing Strategies

Local Development Testing

Use the web-push CLI to send test notifications directly to a subscription endpoint without setting up the full server pipeline. Generate VAPID keys, capture the subscription object from browser DevTools (Application > Service Workers > Push), and send test payloads:

npx web-push send-notification \
  --endpoint="https://fcm.googleapis.com/fcm/send/..." \
  --key="BDx..." \
  --auth="abc..." \
  --payload='{"title":"Test","body":"Hello!"}' \
  --vapid-pubkey="BDx..." \
  --vapid-pvtkey="abc..." \
  --subject="mailto:test@example.com"

Integration Testing with Playwright

import { test, expect } from '@playwright/test';
 
test('subscribes to push notifications', async ({ page, context }) => {
  // Grant notification permission
  await context.grantPermissions(['notifications']);
 
  await page.goto('/settings');
  await page.click('[data-testid="enable-notifications"]');
 
  // Verify subscription request was sent
  const request = await page.waitForRequest('/api/push/subscribe');
  const body = request.postDataJSON();
  expect(body.endpoint).toBeTruthy();
  expect(body.keys.p256dh).toBeTruthy();
  expect(body.keys.auth).toBeTruthy();
});

Future Outlook

The web push ecosystem continues evolving with several key developments. Notification Triggers (Chrome origin trial) enable time-based notifications delivered without an active service worker connection. The Badging API (navigator.setAppBadge()) complements push notifications with unread count badges on PWA icons. Safari's implementation continues improving with each release, narrowing the gap with Chrome and Firefox.

Web push is increasingly integrated with the broader PWA platform. File handling, protocol handling, and URL handling capabilities allow PWAs to register as default handlers, creating native-like experiences where push notifications seamlessly launch the correct context.

Conclusion

Web push notifications provide a powerful, cost-free re-engagement channel that works across all modern browsers. The implementation requires careful attention to VAPID key management, subscription lifecycle handling, and notification design. Key takeaways: request permission contextually after demonstrating value; implement robust subscription expiration handling; design rich notifications with images and action buttons; respect rate limits and user quiet hours; and use established libraries for cryptographic operations.

Start with a targeted use case (cart recovery, breaking news, or task updates), measure engagement metrics rigorously, and expand based on data. The combination of push notifications with service worker background sync creates opportunities for sophisticated offline-first experiences that rival native applications.

Official resources: Web Push Protocol RFC 8030, VAPID RFC 8292, MDN Push API, web-push npm package.