· deepdives  · 7 min read

Mastering the Notifications API: Building Engaging User Experiences

Learn how to design, implement, and optimize web notifications - from permission flows and service workers to server-side push delivery and UX best practices - so your app delivers timely, respectful, and engaging messages.

Learn how to design, implement, and optimize web notifications - from permission flows and service workers to server-side push delivery and UX best practices - so your app delivers timely, respectful, and engaging messages.

What you’ll achieve

By the end of this guide you’ll be able to add reliable, respectful web notifications to your app that increase engagement without annoying users. You’ll know when to use local notifications vs push, how to register a service worker and send push messages, how to design permission UX, and which pitfalls to avoid.

Short. Practical. Actionable.

Quick overview: what the Notifications API (and Push) actually do

  • The Notifications API (window.Notification) shows a system notification while the page is open (or via a service worker).
  • The Push API lets a server push messages to a service worker even when the page is closed.
  • Together they enable persistent, cross-tab, and background notifications for modern web apps.

Use Notifications API when you can show a notification directly from the page. Use Push + Service Worker when you need server-initiated messages while the user isn’t on the page.

References: MDN docs on the Notifications API and Push API are excellent starting points: https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API and https://developer.mozilla.org/en-US/docs/Web/API/Push_API.

The high-level flow

  1. Ask the user for permission (and do it respectfully).
  2. If granted, either display notifications directly from the page, or subscribe to push notifications via a Service Worker.
  3. If using push: send the subscription to your server and use a push service (with VAPID) to deliver messages.
  4. Handle interactions (clicks, actions) in the service worker to route users into your app.

Permission best practices - the most important UX decision

Permission is a scarce resource. Ask only when the user clearly expects value. Wait for a meaningful moment (e.g., when a user opts into order updates or finishes onboarding) rather than requesting on page load.

Do this instead of the default browser prompt:

  • Show an in-app banner or tooltip explaining the benefit.
  • When users click “Enable notifications”, then call Notification.requestPermission().

Example pattern (in-app prompt -> browser prompt):

// show your own UI first; when user accepts, call the browser prompt
async function askAndRequest() {
  // user clicked your in-app 'Enable notifications' button
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    console.log('User granted notifications');
  } else {
    console.log('User denied notifications');
  }
}

Never call Notification.requestPermission() on page load. It harms conversion and trust.

Basic in-page notification (no service worker)

Quick and useful for transient notifications while a tab is open:

if (Notification.permission === 'granted') {
  const n = new Notification('New message', {
    body: 'You have a new message from Alex',
    icon: '/images/msg-icon.png',
    tag: 'chat-123', // prevents duplicate notifications
    data: { conversationId: 123 },
  });

  n.onclick = e => {
    window.focus();
    // navigate or open chat
    window.location.href = '/chat/123';
  };
}

But this only works when the page is open. For server-initiated messages or when the browser is closed, use Push API + Service Worker.

Push notifications: implementation step-by-step

You need three things:

  1. A Service Worker on the client to receive push messages.
  2. A PushSubscription from the Push Manager that you send to your server.
  3. A server that sends push messages via a push service (using VAPID keys for authentication).

1) Register a service worker and subscribe

// in main.js
if ('serviceWorker' in navigator && 'PushManager' in window) {
  await navigator.serviceWorker.register('/sw.js');
  const reg = await navigator.serviceWorker.ready;

  // subscribe:
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array('<YOUR_PUBLIC_VAPID_KEY>'),
  });

  // send subscription to server so it can send pushes
  await fetch('/api/save-subscription', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(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 rawData = atob(base64);
  return Uint8Array.from([...rawData].map(c => c.charCodeAt(0)));
}

2) Service worker: listen for push and show notification

Create /sw.js:

self.addEventListener('push', event => {
  let payload = {};
  if (event.data) {
    payload = event.data.json();
  }

  const title = payload.title || 'New notification';
  const options = {
    body: payload.body || '',
    icon: payload.icon || '/images/default-icon.png',
    badge: payload.badge || '/images/badge.png',
    data: payload.data || {},
    actions: payload.actions || [], // buttons
  };

  event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener('notificationclick', event => {
  event.notification.close();
  const urlToOpen = event.notification.data && event.notification.data.url;

  event.waitUntil(
    clients
      .matchAll({ type: 'window', includeUncontrolled: true })
      .then(clientList => {
        // focus existing tab if open, otherwise open a new one
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client)
            return client.focus();
        }
        if (clients.openWindow) return clients.openWindow(urlToOpen || '/');
      })
  );
});

3) Server: sending a push message (Node.js example with web-push)

Use the web-push library to send web push messages. Generate VAPID keys and keep the private key secret.

// server.js
const webpush = require('web-push');

const vapidKeys = {
  publicKey: '<YOUR_PUBLIC_VAPID_KEY>',
  privateKey: '<YOUR_PRIVATE_VAPID_KEY>',
};

webpush.setVapidDetails(
  'mailto:admin@example.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

// subscription is the JSON object your client POSTed earlier
async function sendPush(subscription, payload) {
  await webpush.sendNotification(subscription, JSON.stringify(payload));
}

// usage
const payload = {
  title: 'Order shipped',
  body: 'Your order #123 is on the way!',
  data: { url: '/orders/123' },
};
sendPush(savedSubscription, payload).catch(console.error);

Note: some push services limit payload size. Consider sending minimal payload and fetching details when the user interacts with the notification.

Advanced features you should use

  • actions: add action buttons in notifications and respond in service worker’s notificationclick.
  • tag + renotify: coalesce updates into a single notification for a given context.
  • requireInteraction: keep notification visible until user acts (use sparingly).
  • data: attach meeting IDs, URLs, or anything the click handler needs.

Example options using actions:

const options = {
  actions: [
    { action: 'open', title: 'Open', icon: '/icons/open.png' },
    { action: 'dismiss', title: 'Dismiss' },
  ],
  tag: 'order-123',
  renotify: true,
};

Handle action in service worker:

self.addEventListener('notificationclick', event => {
  if (event.action === 'open') {
    // open order page
  } else {
    // default click
  }
});

Cross-browser compatibility and progressive enhancement

  • Notifications and Push require HTTPS (except on localhost).
  • Support varies across browsers and platforms. Use feature detection (“serviceWorker” in navigator, “PushManager” in window, “Notification” in window).
  • On iOS Safari: historically push support was missing; check current status at Can I Use: https://caniuse.com/.
  • Provide fallbacks for critical alerts (email, SMS) if push isn’t available.

Testing and debugging

  • Use Chrome devtools > Application > Service Workers and Push to inspect registered sw and subscriptions.
  • Use the web-push library and curl to send test messages.
  • Listen for subscription errors and unsubscribes on the server and clean up invalid endpoints.

Metrics and analytics

Track these to measure effectiveness:

  • Opt-in rate (how many users grant permission).
  • Delivered vs clicked notifications.
  • Conversion rate after click (did the user perform the desired action?).
  • Unsubscribe rate (how many users revoke permission or unsubscribe server-side).

Keep privacy in mind: avoid linking push IDs to sensitive personally identifiable data unless necessary and legal.

Security and privacy

  • Use HTTPS and VAPID keys for authentication.
  • Never store private VAPID keys on the client.
  • Minimize payload size and avoid sending sensitive data in the push payload.
  • Respect user privacy: store minimal metadata and honor unsubscribe requests quickly.

Common pitfalls and how to avoid them

  • Prompting too early - wait for user intent.
  • Over-notifying - frequency kills engagement. Use limits and quiet hours.
  • Ignoring action handlers - if you provide actions, make them useful and responsive.
  • Not handling revoked permissions - detect and surface value to re-opt-in paths.
  • Using notifications for critical workflows that require guaranteed delivery - push is best-effort, not 100% reliable.

Real-world examples and when to use them

  • Chat apps: deliver messages with a conversation tag to avoid duplicates. Use conversationId in data so a click opens the right chat.
  • News apps: breaking news with significant value. Use high-priority sparingly.
  • E‑commerce: cart abandonment reminders, order updates, and price drops. Personalize and time them around user behavior.
  • Productivity/reminders: calendar alerts and task reminders when user expects them.

Example: a chat notification should update (replace) previous notifications about the same conversation instead of spamming the user. Use tag and renotify.

Accessibility

  • Screen readers may not read notifications automatically. Provide in-app alternatives for users relying on assistive tech.
  • Use clear titles and context in the notification body.
  • Ensure actions are meaningful (“Mark done” vs “Action 1”).

Lifecycle: subscribing, renewing, unsubscribing

  • Keep track of subscriptions server-side. When push.sendNotification fails with a 410 or NotRegistered error, remove the subscription.
  • Allow easy unsubscribe in UI and provide an endpoint to clean up server records.

Unsubscribe example (client-side):

const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.getSubscription();
if (subscription) {
  await subscription.unsubscribe();
  await fetch('/api/remove-subscription', {
    method: 'POST',
    body: JSON.stringify(subscription),
  });
}

Summary - the essential checklist

  • Delay permission request until a clear value moment.
  • Use in-page Notification API for ephemeral, tab-bound alerts.
  • Use Push + Service Worker for server-initiated, background-capable notifications.
  • Generate VAPID keys, keep private key secure, and use a library like web-push to send notifications.
  • Design for frequency, context, and accessibility. Test and measure.

Do this well and you give users relevant moments of value. Do it poorly and you break trust. Choose value over volume. Respect permissions. Win engagement by being helpful, timely, and considerate.

Further reading

Back to Blog

Related Posts

View All Posts »
Unlocking Real-Time Notifications: A Deep Dive into the Push API

Unlocking Real-Time Notifications: A Deep Dive into the Push API

Learn how to add real-time push notifications to your PWA. This deep dive covers the Push API, service worker integration, VAPID keys, server-side sending with Node.js, handling payloads, cross-browser considerations, and best practices to keep users happy.