Loading learning content...
For years, push notifications were the exclusive domain of native mobile applications. Web applications—bound by the browser sandbox—could only receive real-time updates through polling, long-polling, or WebSockets, all of which required the browser to remain open. Then came Web Push: a standardized protocol that enables web applications to receive push notifications even when the browser is closed.
Web Push represents a fundamental shift in web platform capabilities. A user can close their browser, go about their day, and still receive timely notifications from your web application—just like they would from a native app. For businesses, this dramatically increases re-engagement opportunities. For engineers, it introduces an entirely new architecture with its own authentication mechanisms, browser APIs, and delivery considerations.
While conceptually similar to mobile push, web push is technically distinct. It uses the Web Push Protocol (RFC 8030), VAPID authentication (RFC 8292), and relies on browser-specific push services (Google's push service for Chrome, Mozilla's for Firefox, etc.). Your application never communicates with 'the browser' directly—it sends messages to push services that then deliver to browsers.
This page provides a thorough exploration of web push notifications, from the underlying protocols and standards to practical implementation patterns. You'll understand how service workers receive push events, how VAPID authentication works, and how to build reliable web push infrastructure.
Understanding web push architecture requires grasping three distinct layers: the browser APIs your web application uses, the push services operated by browser vendors, and the protocols that connect them.
The Four Participants:
Your Web Application: JavaScript running in the browser that requests permission and manages subscriptions.
Service Worker: A background script registered by your web app that receives push events even when the main page is closed.
Push Service: Infrastructure operated by browser vendors (Google, Mozilla, Microsoft, Apple) that maintains connections with browsers and delivers push messages.
Your Application Server: Your backend that stores subscriptions and sends push messages to push services.
The Subscription Flow:
1234567891011121314151617181920212223242526272829303132
# Web Push Subscription Flow ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ Web App │ │ Service │ │ Push │ │ Your ││ (Browser) │ │ Worker │ │ Service │ │ Server │└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ │ │ 1. Register SW │ │ │ │ ─────────────────> │ │ │ │ │ │ │ │ 2. Request Push │ │ │ │ Permission │ │ │ │ <────────────────── │ │ │ │ │ │ │ │ 3. User Grants │ │ │ │ Permission │ │ │ │ ─────────────────> │ │ │ │ │ │ │ │ 4. Subscribe to │ │ │ │ Push Manager │ │ │ │ ─────────────────────────────────────────> │ │ │ │ │ │ │ 5. PushSubscription │ │ │ │ (endpoint + keys)│ │ │ │ <───────────────────────────────────────── │ │ │ │ │ │ │ 6. Send Subscription to Server │ │ │ ────────────────────────────────────────────────────────────────> │ │ │ │ │ │ │ │ 7. Store │ │ │ │ Subscription │ │ │ │ │The Push Message Flow:
123456789101112131415161718192021222324252627282930
# Web Push Message Delivery Flow ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ Your │ │ Push │ │ Service │ │ Web App ││ Server │ │ Service │ │ Worker │ │ (Browser) │└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ │ │ 1. POST to endpoint │ │ │ │ (encrypted payload) │ │ │ ─────────────────> │ │ │ │ │ │ │ │ 2. 201 Created │ │ │ │ <───────────────────│ │ │ │ │ │ │ │ │ 3. Deliver to │ │ │ │ Browser │ │ │ │ ────────────────> │ │ │ │ │ │ │ │ │ 4. 'push' event │ │ │ │ fired in SW │ │ │ │ │ │ │ │ 5. SW handles │ │ │ │ shows notification │ │ │ │ ──────────────────> │ │ │ │ │ │ │ │ 6. User clicks │ │ │ │ <────────────────── │ │ │ │ │ │ │ │ 7. Open app/URL │ │ │ │ ──────────────────> │When a user subscribes, the browser returns a PushSubscription object containing an endpoint URL. This URL points to the browser vendor's push service—fcm.googleapis.com for Chrome, updates.push.services.mozilla.com for Firefox, etc. Your server never needs to know which browser is being used; just POST to the endpoint.
Service workers are the foundation of web push. They're JavaScript workers that run in the background, separate from the main page thread, and can intercept network requests, cache resources, and—critically for push notifications—receive push messages even when no browser tabs are open.
Service Worker Lifecycle:
Registration: Your web app registers the service worker using navigator.serviceWorker.register().
Installation: The service worker downloads and the install event fires. This is where you typically cache static assets.
Activation: After installation, the activate event fires. The SW now controls pages.
Idle/Wake: The SW goes idle when not needed and wakes for events (including push).
Push Event Handling:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// service-worker.js - Web Push Event Handler // Push event fires when a push message is receivedself.addEventListener('push', function(event) { console.log('[Service Worker] Push received:', event); let notificationData = { title: 'Default Title', body: 'Default body text', icon: '/icons/notification-icon.png', badge: '/icons/badge-icon.png', data: {} }; // Extract payload if present if (event.data) { try { const payload = event.data.json(); notificationData = { title: payload.title || notificationData.title, body: payload.body || notificationData.body, icon: payload.icon || notificationData.icon, badge: payload.badge || notificationData.badge, data: payload.data || {}, // Additional options image: payload.image, // Large image to display actions: payload.actions, // Action buttons tag: payload.tag, // Group/replace notifications renotify: payload.renotify, // Vibrate on replace requireInteraction: payload.requireInteraction, silent: payload.silent, timestamp: payload.timestamp, }; } catch (e) { // Text payload notificationData.body = event.data.text(); } } // IMPORTANT: Must call waitUntil to keep SW alive during async operation event.waitUntil( self.registration.showNotification(notificationData.title, { body: notificationData.body, icon: notificationData.icon, badge: notificationData.badge, image: notificationData.image, actions: notificationData.actions, tag: notificationData.tag, data: notificationData.data, requireInteraction: notificationData.requireInteraction, silent: notificationData.silent, timestamp: notificationData.timestamp, }) );}); // Notification click eventself.addEventListener('notificationclick', function(event) { console.log('[Service Worker] Notification clicked:', event); event.notification.close(); // Close the notification // Get data from the notification const notificationData = event.notification.data; const actionClicked = event.action; // Which action button was clicked let targetUrl = notificationData.url || '/'; // Handle specific action buttons if (actionClicked === 'reply') { targetUrl = notificationData.replyUrl || '/reply'; } else if (actionClicked === 'dismiss') { // Just close, don't open anything return; } // Open or focus the appropriate window event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(function(clientList) { // Check if a window is already open for (const client of clientList) { if (client.url.includes(self.location.origin) && 'focus' in client) { client.focus(); return client.navigate(targetUrl); } } // No existing window - open new one return clients.openWindow(targetUrl); }) );}); // Notification close event (dismissed without clicking)self.addEventListener('notificationclose', function(event) { console.log('[Service Worker] Notification closed:', event); // Track dismissal for analytics const notificationData = event.notification.data; event.waitUntil( // Send analytics event (fire and forget) fetch('/api/notifications/dismissed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notificationId: notificationData.notificationId, timestamp: Date.now() }) }).catch(() => { // Ignore errors - analytics is best-effort }) );});Always wrap asynchronous operations in event.waitUntil(). Without it, the browser may terminate the service worker before your async operation completes, resulting in missed notifications or incomplete processing.
Client-Side Subscription:
On the main page, you need to request permission, subscribe to push, and send the subscription to your server:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// Client-side push subscription management class WebPushManager { constructor(vapidPublicKey, serverEndpoint) { // VAPID public key (base64url encoded) this.vapidPublicKey = vapidPublicKey; this.serverEndpoint = serverEndpoint; this.swRegistration = null; } async initialize() { // Check browser support if (!('serviceWorker' in navigator) || !('PushManager' in window)) { throw new Error('Push notifications not supported'); } // Register service worker this.swRegistration = await navigator.serviceWorker.register('/sw.js'); console.log('Service Worker registered:', this.swRegistration); // Wait for the service worker to be ready await navigator.serviceWorker.ready; return this; } async getPermissionState() { const permission = await this.swRegistration.pushManager.permissionState({ userVisibleOnly: true }); return permission; // 'granted', 'denied', or 'prompt' } async subscribe() { // Request permission const permission = await Notification.requestPermission(); if (permission !== 'granted') { throw new Error('Notification permission denied'); } // Subscribe to push const subscription = await this.swRegistration.pushManager.subscribe({ userVisibleOnly: true, // Required: must show notification applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) }); console.log('Push subscription:', JSON.stringify(subscription)); // Send subscription to server await this.sendSubscriptionToServer(subscription); return subscription; } async unsubscribe() { const subscription = await this.swRegistration.pushManager.getSubscription(); if (subscription) { // Unsubscribe from push await subscription.unsubscribe(); // Notify server await fetch(`${this.serverEndpoint}/unsubscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: subscription.endpoint }) }); } } async getExistingSubscription() { return await this.swRegistration.pushManager.getSubscription(); } async sendSubscriptionToServer(subscription) { const response = await fetch(`${this.serverEndpoint}/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription.toJSON()) }); if (!response.ok) { throw new Error('Failed to save subscription on server'); } } // Convert base64url to Uint8Array for applicationServerKey urlBase64ToUint8Array(base64String) { 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; }} // Usageconst pushManager = new WebPushManager( 'BBr5GIeWhPvVjaGk8HQ5F4P3GnN...', // Your VAPID public key '/api/push'); await pushManager.initialize(); // Check current stateif (await pushManager.getExistingSubscription()) { console.log('Already subscribed');} else if (await pushManager.getPermissionState() !== 'denied') { // Can request subscription await pushManager.subscribe();}VAPID (Voluntary Application Server Identification) is the standard authentication mechanism for web push. Defined in RFC 8292, it allows your application server to identify itself to push services, enabling the push service to attribute messages to your server and potentially contact you if issues arise.
How VAPID Works:
Key Generation: Generate an ECDSA key pair (P-256 curve) offline. The public key is shared with browsers (applicationServerKey in subscriptions). The private key stays on your server.
JWT Creation: For each push request, create a JWT signed with your private key. The JWT contains claims identifying your server.
Request Headers: Include the JWT in the Authorization header and your public key in the Crypto-Key header.
Verification: The push service verifies the JWT signature using your public key, confirming the request came from your server.
VAPID JWT Structure:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
# VAPID JWT generation for Web Push import jwtimport timefrom cryptography.hazmat.primitives import serializationfrom cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.backends import default_backendimport base64 class VAPIDAuthenticator: """ Generates VAPID authentication headers for web push requests. VAPID JWT claims: - aud: The push service origin (e.g., https://fcm.googleapis.com) - exp: Expiration time (max 24 hours from iat) - sub: Contact information (mailto: or https: URL) """ # JWT valid for 12 hours (less than 24 hour max) TOKEN_LIFETIME_SECONDS = 43200 def __init__(self, private_key_pem: str, public_key_bytes: bytes, contact_info: str): """ Initialize with VAPID key pair. Args: private_key_pem: PEM-encoded EC private key public_key_bytes: Raw public key bytes (65 bytes for P-256) contact_info: Your contact URL (mailto:email or https://url) """ self.private_key = serialization.load_pem_private_key( private_key_pem.encode(), password=None, backend=default_backend() ) self.public_key_bytes = public_key_bytes self.contact_info = contact_info # Cache tokens per audience self._token_cache = {} @classmethod def generate_keypair(cls) -> tuple[str, str, bytes]: """ Generate a new VAPID key pair. Returns: Tuple of (private_key_pem, public_key_base64url, public_key_bytes) """ private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) # Export private key as PEM private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ).decode() # Export public key as uncompressed point public_key = private_key.public_key() public_bytes = public_key.public_bytes( encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint ) # Base64url encode for client-side use public_base64url = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode() return private_pem, public_base64url, public_bytes def get_authorization_headers(self, push_service_origin: str) -> dict: """ Get the headers required for VAPID authentication. Args: push_service_origin: The origin of the push service (e.g., https://fcm.googleapis.com) Returns: Dict with Authorization and Crypto-Key headers """ token = self._get_token(push_service_origin) return { 'Authorization': f'vapid t={token}, k={self._get_public_key_base64url()}', # Alternative header format (older): # 'Authorization': f'WebPush {token}', # 'Crypto-Key': f'p256ecdsa={self._get_public_key_base64url()}' } def _get_token(self, audience: str) -> str: """ Get a valid JWT for the given audience, using cache when possible. """ now = time.time() # Check cache cached = self._token_cache.get(audience) if cached and cached['expires_at'] > now + 300: # 5 min buffer return cached['token'] # Generate new token token = self._generate_token(audience, now) self._token_cache[audience] = { 'token': token, 'expires_at': now + self.TOKEN_LIFETIME_SECONDS } return token def _generate_token(self, audience: str, issued_at: float) -> str: """Generate a new VAPID JWT.""" payload = { 'aud': audience, 'exp': int(issued_at) + self.TOKEN_LIFETIME_SECONDS, 'sub': self.contact_info, } return jwt.encode(payload, self.private_key, algorithm='ES256') def _get_public_key_base64url(self) -> str: """Get base64url-encoded public key.""" return base64.urlsafe_b64encode(self.public_key_bytes).rstrip(b'=').decode() # One-time key generationprivate_pem, public_base64url, public_bytes = VAPIDAuthenticator.generate_keypair()print(f"Public key for client: {public_base64url}")# Save private_pem securely! # Runtime usagevapid = VAPIDAuthenticator( private_key_pem=private_pem, public_key_bytes=public_bytes, contact_info="mailto:admin@yourapp.com") # Get headers for a push requestheaders = vapid.get_authorization_headers("https://fcm.googleapis.com")VAPID keys should be long-lived (years). Rotating keys requires all users to re-subscribe, as the applicationServerKey is bound to the subscription. Plan key rotation carefully—it's essentially a migration event.
Unlike APNs and FCM where payloads are transmitted with transport-layer encryption only, web push mandates end-to-end encryption of message content. The push service cannot read your notification payloads—only the receiving browser can decrypt them.
Why End-to-End Encryption?
Web push was designed with privacy as a core principle. Since push services are operated by browser vendors (who may log or inspect traffic), the protocol ensures that message content is encrypted before leaving your server and can only be decrypted by the destination browser. This protects user privacy even if push services are compromised.
Encryption Mechanism:
Web push uses Elliptic Curve Diffie-Hellman (ECDH) for key agreement and AES-128-GCM for content encryption. The subscription includes keys for encryption:
Encryption Standards:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
# Web Push message encryption# In practice, use a library like pywebpush, but here's the concept: from cryptography.hazmat.primitives.asymmetric import ecfrom cryptography.hazmat.primitives.kdf.hkdf import HKDFfrom cryptography.hazmat.primitives import hashesfrom cryptography.hazmat.primitives.ciphers.aead import AESGCMimport osimport struct class WebPushEncryptor: """ Encrypts web push message payloads using aes128gcm encoding. The encryption uses: 1. ECDH key agreement between sender and receiver 2. HKDF key derivation for the actual encryption key 3. AES-128-GCM for content encryption NOTE: This is a simplified illustration. Use a production library like 'pywebpush' for actual implementations. """ def encrypt( self, plaintext: bytes, subscription: dict ) -> tuple[bytes, bytes, bytes]: """ Encrypt a message for web push delivery. Args: plaintext: The notification payload (JSON bytes) subscription: PushSubscription object with keys Returns: Tuple of (ciphertext, salt, server_public_key) """ # Extract receiver's keys from subscription receiver_public_key = base64url_decode(subscription['keys']['p256dh']) auth_secret = base64url_decode(subscription['keys']['auth']) # Generate ephemeral sender key pair sender_private_key = ec.generate_private_key(ec.SECP256R1()) sender_public_key = sender_private_key.public_key() # ECDH shared secret shared_secret = sender_private_key.exchange( ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point( ec.SECP256R1(), receiver_public_key ) ) # Generate salt salt = os.urandom(16) # Derive encryption key using HKDF # This follows the Web Push encryption specification # PRK = HKDF-Extract(auth_secret, ecdh_shared_secret) prk = HKDF( algorithm=hashes.SHA256(), length=32, salt=auth_secret, info=b'WebPush: info\x00' + receiver_public_key + sender_public_key_bytes, ).derive(shared_secret) # Derive content encryption key cek = HKDF( algorithm=hashes.SHA256(), length=16, salt=salt, info=b'Content-Encoding: aes128gcm\x00', ).derive(prk) # Derive nonce nonce = HKDF( algorithm=hashes.SHA256(), length=12, salt=salt, info=b'Content-Encoding: nonce\x00', ).derive(prk) # Pad plaintext (required by spec) # Record size is typically 4096 bytes padded = self._pad(plaintext, 4096) # Encrypt with AES-128-GCM aesgcm = AESGCM(cek) ciphertext = aesgcm.encrypt(nonce, padded, None) # Build encrypted content block # Format: salt (16) | rs (4) | idlen (1) | keyid | ciphertext sender_public_key_bytes = sender_public_key.public_bytes( encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint ) record_size = 4096 encrypted_content = ( salt + struct.pack('>I', record_size) + # 4 bytes big-endian struct.pack('B', len(sender_public_key_bytes)) + # 1 byte sender_public_key_bytes + ciphertext ) return encrypted_content def _pad(self, data: bytes, record_size: int) -> bytes: """ Pad data according to aes128gcm requirements. Padding: delimiter (0x02) + zero padding to reach record size (minus 16 bytes for auth tag, minus 1 for delimiter min) """ max_data_size = record_size - 17 # 16 for tag, 1 for delimiter if len(data) > max_data_size: raise ValueError("Data too large for record size") padding_length = max_data_size - len(data) return data + b'\x02' + (b'\x00' * padding_length)Web push encryption is complex and error-prone. Always use established libraries like 'pywebpush' (Python), 'web-push' (Node.js), or similar. The above code is educational—real implementations require careful handling of key formats, padding, and edge cases.
With encryption and authentication understood, let's examine the complete flow for sending web push notifications from your server.
Request Structure:
Web push requests are HTTP POST requests to the subscription endpoint with the encrypted payload as the body:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
# Complete web push sending implementation using pywebpush from pywebpush import webpush, WebPushExceptionimport json class WebPushService: """ Service for sending web push notifications. Uses pywebpush library which handles: - VAPID authentication - Payload encryption (aes128gcm) - Proper header formatting """ def __init__( self, vapid_private_key: str, vapid_claims: dict ): """ Initialize with VAPID credentials. Args: vapid_private_key: Your VAPID private key (base64url or PEM) vapid_claims: Dict with 'sub' claim (mailto: or https: URL) """ self.vapid_private_key = vapid_private_key self.vapid_claims = vapid_claims def send( self, subscription: dict, payload: dict, ttl: int = 86400, urgency: str = 'normal', topic: str | None = None ) -> SendResult: """ Send a push notification to a subscription. Args: subscription: The PushSubscription object from the browser {endpoint, keys: {p256dh, auth}} payload: Notification data (will be JSON serialized) ttl: Time-to-live in seconds (how long service keeps message) urgency: 'very-low', 'low', 'normal', or 'high' topic: Optional topic for message replacement Returns: SendResult with success status and details """ try: # Prepare headers headers = {} if urgency: headers['Urgency'] = urgency if topic: headers['Topic'] = topic # Send the push response = webpush( subscription_info=subscription, data=json.dumps(payload), vapid_private_key=self.vapid_private_key, vapid_claims=self.vapid_claims, ttl=ttl, headers=headers ) return SendResult( success=True, status_code=response.status_code, message_id=response.headers.get('Location') ) except WebPushException as e: return self._handle_error(e, subscription) def send_batch( self, subscriptions: list[dict], payload: dict, ttl: int = 86400 ) -> BatchSendResult: """ Send the same notification to multiple subscriptions. Note: Web push doesn't have a native batch API like FCM. We send individual requests (can be parallelized). """ results = [] for subscription in subscriptions: result = self.send(subscription, payload, ttl) results.append(result) return BatchSendResult( total=len(subscriptions), successful=sum(1 for r in results if r.success), failed=sum(1 for r in results if not r.success), results=results ) def _handle_error( self, error: WebPushException, subscription: dict ) -> SendResult: """ Handle web push errors with appropriate actions. """ status_code = error.response.status_code if error.response else None # 404 or 410: Subscription no longer valid if status_code in (404, 410): return SendResult( success=False, status_code=status_code, error='SUBSCRIPTION_INVALID', should_remove=True # Flag to remove from database ) # 429: Rate limited if status_code == 429: retry_after = error.response.headers.get('Retry-After', 60) return SendResult( success=False, status_code=status_code, error='RATE_LIMITED', retry_after=int(retry_after) ) # 5xx: Server error if status_code and status_code >= 500: return SendResult( success=False, status_code=status_code, error='SERVER_ERROR', should_retry=True ) # Other errors return SendResult( success=False, status_code=status_code, error=str(error), should_retry=False ) # Usage examplepush_service = WebPushService( vapid_private_key='YOUR_PRIVATE_KEY_BASE64URL_OR_PEM', vapid_claims={ 'sub': 'mailto:admin@yourapp.com' }) # Subscription from browsersubscription = { 'endpoint': 'https://fcm.googleapis.com/fcm/send/...', 'keys': { 'p256dh': 'user_public_key_base64url', 'auth': 'auth_secret_base64url' }} # Send notificationresult = push_service.send( subscription=subscription, payload={ 'title': 'New Message', 'body': 'You have a new message from John', 'icon': '/icons/message.png', 'data': { 'url': '/messages/123', 'messageId': '123' } }, ttl=86400, # 24 hours urgency='high') if result.should_remove: # Remove invalid subscription from database db.delete_subscription(subscription['endpoint'])| Header | Required | Description |
|---|---|---|
| Authorization | Yes | VAPID JWT token for authentication |
| Content-Encoding | Yes | Must be 'aes128gcm' |
| Content-Type | Yes | Must be 'application/octet-stream' |
| TTL | Yes | Time-to-live in seconds (0 = don't store) |
| Urgency | No | very-low, low, normal, high |
| Topic | No | Group/replace messages with same topic |
Web push enjoys broad browser support, but with significant variations in capabilities and behavior. Understanding these differences is essential for a consistent user experience.
Browser Support Matrix:
| Browser | Desktop | Mobile | Notes |
|---|---|---|---|
| Chrome | Full support | Full (Android) | Uses FCM as push service |
| Firefox | Full support | Full (Android) | Uses Mozilla push service |
| Edge | Full support | Full (Android) | Uses WNS/FCM |
| Safari | Full support (macOS 13+) | Full (iOS 16.4+) | Requires VAPID, added PWA support |
| Opera | Full support | Full (Android) | Uses FCM |
| Samsung Internet | N/A | Full support | Uses FCM |
After years of being the only major browser without web push support, Safari added support in macOS Ventura (13.0) for desktop and iOS 16.4 for mobile (as Home Screen web apps only). This significantly expands the reach of web push to Apple device users.
Key Limitations and Considerations:
Payload Size:
iOS/Safari Specifics:
Notification Display:
Permission UX:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// Browser-specific push handling class PushCapabilities { static detect() { const capabilities = { serviceWorker: 'serviceWorker' in navigator, pushManager: 'PushManager' in window, notification: 'Notification' in window, getSubscription: false, // Browser-specific browser: this.detectBrowser(), isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), isMacOS: /Macintosh/.test(navigator.userAgent), isAndroid: /Android/.test(navigator.userAgent), // Additional capabilities permissionState: null, canPrompt: false, }; return capabilities; } static detectBrowser() { const ua = navigator.userAgent; if (/Safari/.test(ua) && !/Chrome/.test(ua)) { return 'safari'; } else if (/Firefox/.test(ua)) { return 'firefox'; } else if (/Edg/.test(ua)) { return 'edge'; } else if (/Chrome/.test(ua)) { return 'chrome'; } else if (/Opera|OPR/.test(ua)) { return 'opera'; } return 'unknown'; } static async getPermissionState() { if (!('Notification' in window)) { return 'unsupported'; } // Check current permission if (Notification.permission === 'granted') { return 'granted'; } if (Notification.permission === 'denied') { return 'denied'; } return 'prompt'; // Can ask for permission } static isSafariIOS() { return /iPad|iPhone|iPod/.test(navigator.userAgent) && /Safari/.test(navigator.userAgent) && !('chrome' in window); } static isPWA() { return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; } static canReceivePush() { const caps = this.detect(); // iOS Safari requires PWA if (this.isSafariIOS()) { return this.isPWA(); } return caps.serviceWorker && caps.pushManager && caps.notification; }} // Usageasync function initializePush() { const caps = PushCapabilities.detect(); if (!PushCapabilities.canReceivePush()) { if (PushCapabilities.isSafariIOS() && !PushCapabilities.isPWA()) { // Show iOS PWA installation prompt showAddToHomeScreenPrompt(); return; } // Push not supported console.log('Push notifications not supported'); return; } const permissionState = await PushCapabilities.getPermissionState(); if (permissionState === 'denied') { // User has blocked - show guidance to unblock showUnblockInstructions(); return; } if (permissionState === 'granted') { // Already subscribed - ensure subscription is saved await ensureSubscriptionSaved(); } else { // Can request - show opt-in UI showPushOptInUI(); }}Implementing web push effectively requires attention to user experience, technical reliability, and operational considerations. These best practices are derived from real-world deployments serving millions of users.
Web push is a privilege, not a right. Users will revoke permission (or worse, report as spam) if you abuse it. The rule is simple: every notification must be valuable to the user. If you're sending marketing blasts without clear user benefit, you're building technical debt—decreasing future engagement and increasing unsubscribe rates.
Web push brings native-like notification capabilities to web applications. Here are the essential points to remember:
What's Next:
With mobile and web push covered, we'll next examine notification delivery at scale—how to ensure reliable, timely delivery when sending millions of notifications, handling delivery receipts, troubleshooting delivery issues, and building observability into your push infrastructure.
You now understand web push notifications comprehensively—from the service worker and Push API on the client side, through VAPID authentication and message encryption, to browser compatibility and best practices. This knowledge enables you to extend your push notification infrastructure to web applications.