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.
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.
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',
}
);
}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
-
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.
-
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.
-
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.
-
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).
-
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.
-
Handle subscription expiration gracefully: Monitor
expirationTimeon subscriptions and prompt re-subscription 48 hours before expiry. Implement background sync to retry failed deliveries when connectivity returns. -
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.
-
Use web-push libraries, not raw HTTP: Libraries like
web-push(Node.js),pywebpush(Python), andwebpush(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
| Pitfall | Impact | Solution |
|---|---|---|
| Requesting permission on first visit | 85%+ denial rate; blocks future prompts | Wait for engagement signal, show value proposition first |
| Storing subscriptions without userId association | Cannot target notifications per user | Always link subscriptions to authenticated user accounts |
| Ignoring HTTP 410 responses | Dead subscriptions accumulate, wasting resources | Mark inactive on 410, clean up monthly via cron |
| Sending identical content to all users | Low engagement, high unsubscription rate | Segment by user behavior, personalize with dynamic content |
| Not testing on Safari | Broken notifications for 15-20% of desktop users | Test APNs VAPID flow separately; Safari uses different push service |
| Exposing VAPID private key in client code | Any server can send notifications to your subscribers | Store 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
| Feature | Web Push | SMS | In-App Messaging | |
|---|---|---|---|---|
| Delivery when browser closed | Yes | N/A | N/A | No |
| Requires app installation | No | No | No | Yes |
| Cost per message | Free | $0.001-0.05 | $0.01-0.05 | Free |
| Average open rate | 15-25% | 20-30% | 98% | 40-60% |
| Rich media support | Images, actions | Full HTML | Text only | Full rich media |
| User friction to subscribe | Low | Medium | High | Medium |
| Cross-device sync | Browser-based | Yes | Per-device | Per-app |
| Offline queuing | Yes (service worker) | No | No | Depends |
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.