Introduction to Web Push Notifications
Web push notifications are short, clickable messages sent directly to a user's device through their browser, even when they are not actively visiting your website. Unlike in-app notifications that require the user to have your application open, web push notifications work across desktop and mobile browsers, allowing you to re-engage users with timely updates, promotions, or critical alerts.
At their core, web push notifications rely on two key web technologies: the Push API and the Notifications API. The Push API allows your server to send messages to a service worker running in the user's browser, while the Notifications API handles the actual display of those messages to the user. Together, they create a powerful channel for real-time communication that feels native to the platform.
Why Web Push Notifications Matter
Web push notifications have become an essential tool for modern web applications because they solve a fundamental problem: keeping users engaged over time. Traditional websites lose contact with users the moment they close the tab. With push notifications, you maintain an ongoing relationship that can drive retention, conversions, and user satisfaction.
Key Benefits
- Higher engagement rates: Push notifications typically see open rates between 5% and 15%, significantly higher than email campaigns.
- Real-time communication: Messages are delivered instantly, making them ideal for time-sensitive updates like order status, breaking news, or security alerts.
- No app installation required: Users can receive notifications without downloading anything from an app store, lowering the barrier to engagement.
- Cross-platform reach: A single implementation works across desktop browsers and Android devices, simplifying your development effort.
- Persistent subscriptions: Once a user opts in, their subscription persists across browser sessions until they explicitly unsubscribe.
How Web Push Notifications Work
Before diving into code, it is important to understand the architecture behind web push notifications. The process involves several components working together: your web application, a service worker, a push service operated by the browser vendor, and your application server.
The Subscription Flow
When a user visits your site and grants permission, the browser generates a unique subscription endpoint and a set of encryption keys. This subscription object is sent to your server, where it is stored for future use. When you want to send a notification, your server creates a payload, encrypts it using the subscription's keys, and sends it to the push service endpoint. The push service then delivers the message to the user's browser, where your service worker receives it and displays the notification.
Required Components
- Service Worker: A JavaScript file that runs in the background and listens for push events.
- Manifest file: A JSON file that provides metadata about your web app, though it is optional in modern browsers.
- VAPID keys: Voluntary Application Server Identification keys used to authenticate your server with the push service.
- Application server: Your backend that stores subscriptions and sends push messages.
Setting Up Your Project
Let's build a complete web push notification system from scratch. We will start by generating VAPID keys, then implement the client-side subscription logic, create a service worker, and finally build a server endpoint to send notifications.
Generating VAPID Keys
VAPID keys allow your server to identify itself to the push service. You can generate them using the web-push npm package. First, initialize your project and install the necessary dependencies:
mkdir web-push-demo
cd web-push-demo
npm init -y
npm install web-push express body-parser
Now generate your VAPID key pair:
const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys();
console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);
Save these keys securely. The public key will be used in your client-side JavaScript, while the private key stays on your server. Never expose the private key in your frontend code.
Building the Client Side
The client side is responsible for registering the service worker, requesting permission, and subscribing the user to push notifications. Let's create an HTML page that handles all of this.
Creating the HTML Page
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Push Demo</title>
</head>
<body>
<h1>Web Push Notification Demo</h1>
<button id="subscribeBtn">Enable Notifications</button>
<p id="status"></p>
<script src="app.js"></script>
</body>
</html>
Implementing the Subscription Logic
Create a file named app.js with the following code. This script checks for browser support, registers the service worker, requests notification permission, and sends the subscription to your server.
const PUBLIC_VAPID_KEY = 'YOUR_PUBLIC_VAPID_KEY_HERE';
const SUBSCRIBE_ENDPOINT = 'http://localhost:3000/subscribe';
const subscribeBtn = document.getElementById('subscribeBtn');
const statusEl = document.getElementById('status');
function 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;
}
async function subscribeUser() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
statusEl.textContent = 'Push notifications are not supported in this browser.';
return;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js');
statusEl.textContent = 'Service worker registered. Requesting permission...';
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
statusEl.textContent = 'Notification permission was denied.';
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
});
statusEl.textContent = 'Subscribed! Sending subscription to server...';
await fetch(SUBSCRIBE_ENDPOINT, {
method: 'POST',
body: JSON.stringify(subscription),
headers: {
'Content-Type': 'application/json'
}
});
statusEl.textContent = 'Subscription saved. You will receive push notifications!';
subscribeBtn.disabled = true;
subscribeBtn.textContent = 'Notifications Enabled';
} catch (error) {
statusEl.textContent = 'Subscription failed: ' + error.message;
console.error('Subscription error:', error);
}
}
subscribeBtn.addEventListener('click', subscribeUser);
The urlBase64ToUint8Array helper function converts the base64-encoded VAPID public key into a format the browser can use. The userVisibleOnly: true option is required by browsers and ensures that every push message results in a visible notification to the user.
Creating the Service Worker
The service worker is the heart of the push notification system on the client side. It listens for push events and displays notifications, even when the user does not have your website open. Create a file named sw.js in your project root:
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', (event) => {
let data = { title: 'New Notification', body: 'You have a new message.' };
try {
if (event.data) {
data = event.data.json();
}
} catch (err) {
console.error('Failed to parse push data:', err);
}
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: data.badge || '/badge.png',
data: data.url ? { url: data.url } : {},
actions: data.actions || [],
tag: data.tag || 'default-tag',
renotify: data.renotify || false,
requireInteraction: data.requireInteraction || false,
vibrate: data.vibrate || [200, 100, 200]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url)
? event.notification.data.url
: '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url === targetUrl && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});
This service worker handles three important events. The push event receives the message payload and displays a notification with customizable options like icons, actions, and vibration patterns. The notificationclick event handles what happens when the user clicks the notification, typically focusing an existing tab or opening a new one. The install and activate events ensure the service worker takes control immediately.
Building the Server Side
Your server needs to store subscriptions and send push messages when events occur. We will use Express.js with the web-push library to handle encryption and delivery. Create a file named server.js:
const express = require('express');
const bodyParser = require('body-parser');
const webpush = require('web-push');
const app = express();
app.use(bodyParser.json());
const publicVapidKey = 'YOUR_PUBLIC_VAPID_KEY_HERE';
const privateVapidKey = 'YOUR_PRIVATE_VAPID_KEY_HERE';
webpush.setVapidDetails(
'mailto:your-email@example.com',
publicVapidKey,
privateVapidKey
);
// In-memory storage for demo purposes.
// Use a database in production.
let subscriptions = [];
app.post('/subscribe', (req, res) => {
const subscription = req.body;
subscriptions.push(subscription);
res.status(201).json({ message: 'Subscription added successfully.' });
});
app.post('/notify', (req, res) => {
const payload = JSON.stringify({
title: req.body.title || 'Hello from Server',
body: req.body.body || 'This is a test push notification.',
icon: req.body.icon || null,
url: req.body.url || '/',
tag: req.body.tag || 'notification-' + Date.now()
});
const sendPromises = subscriptions.map((subscription) => {
return webpush.sendNotification(subscription, payload).catch((error) => {
console.error('Failed to send notification:', error.statusCode);
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription is no longer valid, remove it
subscriptions = subscriptions.filter((s) => s.endpoint !== subscription.endpoint);
}
});
});
Promise.all(sendPromises)
.then(() => res.status(200).json({ message: 'Notifications sent.' }))
.catch(() => res.status(500).json({ error: 'Failed to send notifications.' }));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
The /subscribe endpoint receives and stores subscription objects from the client. The /notify endpoint sends push messages to all stored subscriptions. Notice how we handle 410 and 404 status codes — these indicate that a subscription is no longer valid, typically because the user has unsubscribed or cleared their browser data. Removing stale subscriptions is essential for maintaining a clean subscriber list.
Sending a Test Notification
Once your server is running, you can send a test notification using curl or any HTTP client:
curl -X POST http://localhost:3000/notify \
-H "Content-Type: application/json" \
-d '{"title": "Breaking News", "body": "Check out our latest update!", "url": "https://example.com/news"}'
Handling Notification Actions
Notification actions allow you to add interactive buttons directly to the notification. This is useful for scenarios like accepting or declining invitations, marking messages as read, or quick replies. Update your server payload to include actions:
const payload = JSON.stringify({
title: 'New Friend Request',
body: 'John Doe wants to connect with you.',
actions: [
{ action: 'accept', title: 'Accept' },
{ action: 'decline', title: 'Decline' }
],
tag: 'friend-request',
url: 'https://example.com/friends'
});
Then update your service worker to handle these action clicks:
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'accept') {
event.waitUntil(clients.openWindow('https://example.com/friends/accept'));
} else if (event.action === 'decline') {
event.waitUntil(clients.openWindow('https://example.com/friends/decline'));
} else {
// Default click behavior
event.waitUntil(clients.openWindow(event.notification.data.url || '/'));
}
});
Keep in mind that notification actions are supported on Chrome and Firefox on desktop and Android, but support varies on other platforms. Always provide a sensible default behavior for when actions are not available.
Best Practices for Web Push Notifications
Implementing web push notifications is straightforward, but using them effectively requires careful planning. Poorly managed notifications can annoy users and lead to uninstalls or blocked permissions. Follow these best practices to maximize engagement while respecting your users.
Request Permission at the Right Time
Never request notification permission immediately on page load. Users are unlikely to grant permission if they do not understand the value. Instead, use a soft prompt or an in-context button that explains what notifications they will receive. Only call Notification.requestPermission() after the user has shown clear intent.
Send Relevant and Timely Content
Every notification should provide genuine value to the recipient. Segment your audience and personalize messages based on user behavior, preferences, and time zones. Avoid sending generic broadcast messages that are irrelevant to large portions of your subscriber base.
Respect Frequency and Timing
Sending too many notifications will cause users to unsubscribe or disable permissions. Establish a reasonable frequency cap and avoid sending notifications during nighttime hours in the user's local time zone. Consider implementing a preference center where users can choose which types of notifications they want to receive.
Use Tags for Grouping
The tag property allows you to replace existing notifications with the same tag rather than stacking them. This is particularly useful for notifications that represent the same type of event, such as a chat message count or a live score update. Use renotify: true if you want the device to alert the user again even when replacing an existing notification.
Handle Subscription Expiration
Push subscriptions can expire or become invalid over time. Always handle errors gracefully on your server and remove subscriptions that return 410 or 404 status codes. Consider implementing a re-subscription flow on your client side that detects when a subscription is no longer active and creates a new one automatically.
Test Across Browsers and Devices
Web push notification behavior varies across browsers. Chrome, Firefox, and Edge all support the Push API, but Safari has historically had different requirements. Test your implementation thoroughly on all target browsers and both desktop and mobile devices to ensure consistent behavior.
Provide an Easy Unsubscribe Mechanism
Always give users a clear way to opt out of notifications. This can be a settings page in your application or a notification action labeled "Unsubscribe." Respecting user preferences builds trust and reduces the likelihood of users blocking notifications at the browser level, which is much harder to reverse.
Conclusion
Web push notifications are a powerful tool for re-engaging users and delivering timely, relevant information directly to their devices. By combining the Push API with a well-designed service worker and a robust server-side implementation, you can create a notification system that feels native and keeps users coming back. The key to success lies not just in the technical implementation, but in using notifications thoughtfully — sending the right message, to the right user, at the right time. Start with the code examples in this guide, adapt them to your specific use case, and always prioritize the user experience when crafting your notification strategy.