Push notifications let you send messages to users even when they are not actively using your web app. They are one of the most powerful re-engagement tools available to PWAs, bridging the gap between web and native experiences.
How Web Push Works
The push notification system involves three parties:
Your Server → Push Service (FCM/APNs) → User's Browser → Service Worker → Notification
- The user grants notification permission
- The browser creates a push subscription with a push service
- Your server stores the subscription endpoint
- When you want to notify the user, your server sends a message to the push service
- The push service delivers the message to the browser
- The service worker receives the push event and displays a notification
Notification Permission
Before sending any notifications, you must request permission:
async function requestNotificationPermission() {
if (!("Notification" in window)) {
console.log("Notifications not supported");
return false;
}
if (Notification.permission === "granted") {
return true;
}
if (Notification.permission === "denied") {
console.log("Notifications blocked by user");
return false;
}
const permission = await Notification.requestPermission();
return permission === "granted";
}Permission States
| State | Meaning |
|---|---|
default | User has not been asked yet |
granted | User allowed notifications |
denied | User blocked notifications (cannot re-ask) |
Best practice: Do not ask for permission immediately on page load. Wait until the user takes an action that shows they want notifications (like clicking a "Get notified" button).
Simple Notifications (No Push Server)
You can show notifications directly from the page without a push server:
async function showNotification(title, options = {}) {
const hasPermission = await requestNotificationPermission();
if (!hasPermission) return;
const registration = await navigator.serviceWorker.ready;
registration.showNotification(title, {
body: options.body || "",
icon: options.icon || "/icons/icon-192.png",
badge: options.badge || "/icons/badge-72.png",
tag: options.tag || "default",
data: options.data || {},
actions: options.actions || [],
vibrate: [200, 100, 200],
requireInteraction: options.requireInteraction || false,
});
}
// Usage
showNotification("Lesson Complete!", {
body: "You finished 'Service Workers'. Ready for the next one?",
tag: "lesson-complete",
data: { url: "/courses/progressive-web-apps/03-caching-strategies" },
actions: [
{ action: "next", title: "Next Lesson" },
{ action: "dismiss", title: "Later" },
],
});Handling Notification Clicks
In the service worker, listen for notification clicks:
// sw.js
self.addEventListener("notificationclick", (event) => {
const notification = event.notification;
notification.close();
const url = notification.data?.url || "/";
if (event.action === "next") {
event.waitUntil(clients.openWindow(url));
} else if (event.action === "dismiss") {
// Just close the notification
} else {
// Default click (not on an action button)
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
// Focus existing tab if open
for (const client of windowClients) {
if (client.url === url && "focus" in client) {
return client.focus();
}
}
// Otherwise open a new tab
return clients.openWindow(url);
})
);
}
});
self.addEventListener("notificationclose", (event) => {
// Track that the user dismissed the notification
console.log("Notification dismissed:", event.notification.tag);
});Push Subscriptions
To receive push messages from your server, create a push subscription:
Generate VAPID Keys
VAPID (Voluntary Application Server Identification) keys authenticate your server with the push service.
npm install web-push
npx web-push generate-vapid-keysThis outputs a public and private key pair. Store the private key on your server and use the public key in the browser.
Subscribe the User
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // required — must show a notification
applicationServerKey: urlBase64ToUint8Array(
"YOUR_VAPID_PUBLIC_KEY_HERE"
),
});
// Send subscription to your server
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
return subscription;
}
// Helper to convert VAPID key
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from([...raw].map((char) => char.charCodeAt(0)));
}Server-Side Push
Send push messages from your Node.js server:
const webPush = require("web-push");
webPush.setVapidDetails(
"mailto:hello@sabaoon.dev",
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
async function sendPushNotification(subscription, payload) {
try {
await webPush.sendNotification(
subscription,
JSON.stringify(payload)
);
console.log("Push sent successfully");
} catch (error) {
if (error.statusCode === 410) {
// Subscription expired — remove it from your database
console.log("Subscription expired, removing...");
} else {
console.error("Push failed:", error);
}
}
}
// Send to a specific user
sendPushNotification(userSubscription, {
title: "New Course Available!",
body: "Check out Progressive Web Apps on Sabaoon Academy",
url: "/courses/progressive-web-apps",
icon: "/icons/icon-192.png",
});Handling Push Events in the Service Worker
// sw.js
self.addEventListener("push", (event) => {
let data = { title: "New Notification" };
if (event.data) {
try {
data = event.data.json();
} catch {
data = { title: event.data.text() };
}
}
const options = {
body: data.body || "",
icon: data.icon || "/icons/icon-192.png",
badge: "/icons/badge-72.png",
tag: data.tag || "push-notification",
data: { url: data.url || "/" },
actions: data.actions || [],
};
event.waitUntil(self.registration.showNotification(data.title, options));
});Notification Best Practices
Do
- Ask for permission at a contextually relevant moment
- Provide a clear "why" before requesting permission
- Make notifications actionable with relevant deep links
- Use the
tagproperty to replace outdated notifications - Let users control notification preferences in your app
Do Not
- Ask for permission on the first page load
- Send too many notifications (users will block you)
- Send notifications without value to the user
- Use notifications for advertising
- Forget to handle the
notificationclickevent
Building a Notification Preferences UI
class NotificationManager {
constructor() {
this.preferences = this.loadPreferences();
}
loadPreferences() {
const stored = localStorage.getItem("notification-prefs");
return stored
? JSON.parse(stored)
: {
courseUpdates: true,
newContent: true,
reminders: false,
marketing: false,
};
}
savePreferences(prefs) {
this.preferences = prefs;
localStorage.setItem("notification-prefs", JSON.stringify(prefs));
// Sync preferences to server
if (navigator.onLine) {
fetch("/api/notification-preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(prefs),
});
}
}
shouldNotify(category) {
return this.preferences[category] === true;
}
async getPermissionStatus() {
if (!("Notification" in window)) return "unsupported";
return Notification.permission;
}
async isSubscribed() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return subscription !== null;
}
async unsubscribe() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
}
}Practical Exercise
Build a complete push notification system:
// notification-ui.js
const notifBtn = document.getElementById("enable-notifications");
async function setupNotifications() {
const status = await Notification.permission;
if (status === "denied") {
notifBtn.textContent = "Notifications blocked";
notifBtn.disabled = true;
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
notifBtn.textContent = "Disable notifications";
notifBtn.onclick = async () => {
await subscription.unsubscribe();
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
setupNotifications(); // refresh UI
};
} else {
notifBtn.textContent = "Enable notifications";
notifBtn.onclick = async () => {
const hasPermission = await requestNotificationPermission();
if (hasPermission) {
await subscribeToPush();
setupNotifications(); // refresh UI
}
};
}
}
setupNotifications();Key Takeaways
- Push notifications require user permission, a service worker, VAPID keys, and a server-side push library.
- Always ask for notification permission at a relevant moment, not on page load.
- Use the
tagproperty to group and replace related notifications. - Handle
notificationclickin the service worker to deep-link users into your app. - Let users manage their notification preferences and provide an easy way to unsubscribe.