Skip to main content
Progressive Web Apps·Lesson 5 of 5

Push Notifications

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
  1. The user grants notification permission
  2. The browser creates a push subscription with a push service
  3. Your server stores the subscription endpoint
  4. When you want to notify the user, your server sends a message to the push service
  5. The push service delivers the message to the browser
  6. 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

StateMeaning
defaultUser has not been asked yet
grantedUser allowed notifications
deniedUser 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-keys

This 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 tag property 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 notificationclick event

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 tag property to group and replace related notifications.
  • Handle notificationclick in the service worker to deep-link users into your app.
  • Let users manage their notification preferences and provide an easy way to unsubscribe.