Implementing the Notifications API Correctly: Push Without Annoying Users
AI generated
JS
() =>
JavaScript · Browser APIs · Engagement
Implementing the Notifications API Correctly
Push Notifications Without Annoying Users

Few browser features are misused as often as the Notifications API: a permission dialog fired on the very first page visit leads to permanent rejection in the vast majority of cases. Implemented correctly, with proper timing, clear context and service worker integration, the same API becomes a feature users actively request instead of dismissing on sight.

18 min read Notification.requestPermission · Service Worker · Push All modern browsers (with limits on iOS Safari)

1. Why the Notifications API is so often misused

The Notifications API is technically simple, but in practice it is misused more often than it is used correctly. The classic mistake: a website calls Notification.requestPermission() as soon as the page loads, without any context for why notifications would even be useful. Users confronted with an immediate permission dialog reject it in the vast majority of cases, and this rejection is permanent in most browsers, a repeated call to requestPermission() then shows no dialog at all, but immediately returns the previously stored "denied" status.

This pattern has given the Notifications API a bad reputation overall, even though the underlying feature, real system notifications outside the browser window, is enormously valuable for many use cases such as chat apps, calendar reminders or shipping notifications. The real problem is rarely the API itself, but almost always the missing context and wrong timing of the permission request.

This article shows how to implement the Notifications API so that opt-in rates rise noticeably: with delayed timing, an explanatory context before the actual browser dialog, sensible notification options, and correct service worker integration for notifications that arrive even when the tab is closed.

2. Notification.requestPermission in detail

The entry point of the Notifications API is the static method Notification.requestPermission(), which returns a promise that resolves to one of three strings: "granted", "denied" or "default". The state "default" means the user has not yet made a decision, in which case calling requestPermission() shows the native browser dialog. With "granted" or "denied", however, the method returns the stored value immediately, without a new dialog.

An important detail for the Notifications API: as with the File System Access API, the call must originate from a user gesture, in most browsers at least for the initial dialog. A call made directly on page load, without a click event, is technically still executed by modern browsers, but leads to particularly poor opt-in rates because the context for the user is completely missing.


// Basic permission check and request
async function ensureNotificationPermission() {
  if (!("Notification" in window)) {
    console.warn("Notifications API not supported in this browser");
    return "unsupported";
  }

  if (Notification.permission === "granted") {
    return "granted";
  }

  if (Notification.permission === "denied") {
    // Already denied — the browser won't show a dialog again.
    // Show custom UI explaining how to re-enable it manually.
    return "denied";
  }

  // Only call this from within a user gesture, e.g. a button click.
  const permission = await Notification.requestPermission();
  return permission;
}

3. Timing: when the dialog should even appear

The most important lever for higher acceptance rates with the Notifications API is not technical but conceptual in nature: the right timing. Instead of triggering the native permission dialog immediately, a well designed application first shows its own custom, fully styleable hint that explains the concrete benefit, for example "Get notified as soon as your order has shipped". Only once the user actively confirms this custom hint does the application call Notification.requestPermission() and trigger the native browser dialog.

This two step pattern for the Notifications API has a decisive advantage: if the user declines the harmless custom hint, the native dialog is never shown at all, and the valuable one time attempt is preserved for a better moment. The native browser dialog cannot be programmatically triggered again after a rejection in many browsers, the user would have to manually change the permission in the browser's site settings.


// Two-step opt-in: custom prompt first, native dialog only on explicit interest
function showCustomNotificationPrompt() {
  const banner = document.querySelector("#notification-opt-in");
  banner.hidden = false;

  banner.querySelector("[data-action='enable']").addEventListener(
    "click",
    async () => {
      banner.hidden = true;
      const permission = await Notification.requestPermission();
      if (permission === "granted") {
        await subscribeToPushNotifications();
      }
    },
    { once: true }
  );

  banner.querySelector("[data-action='dismiss']").addEventListener(
    "click",
    () => {
      // User said no to our own prompt — never call requestPermission now.
      banner.hidden = true;
      localStorage.setItem("notif-prompt-dismissed", Date.now().toString());
    },
    { once: true }
  );
}

4. Showing notifications and using their options

Once permission is granted, the Notification constructor can display a notification directly while the page is actively open. Besides the title, the constructor accepts an options object with body, icon, tag and data. The tag field is particularly important for the Notifications API: two notifications with the same tag replace each other instead of stacking up, which prevents unnecessary clutter in the notification center for frequently updated content such as chat messages.

The data field carries arbitrary structured metadata that can be read back when the notification is later clicked, for example an order ID or a conversation ID, to navigate directly to the relevant view on click, instead of just opening the homepage.


// Show a foreground notification with useful options
function showOrderShippedNotification(orderId, trackingUrl) {
  if (Notification.permission !== "granted") return;

  const notification = new Notification("Your order has shipped!", {
    body: `Order #${orderId} is on its way.`,
    icon: "/images/icon-192.png",
    tag: `order-${orderId}`, // Replaces any previous notification for this order
    data: { orderId, trackingUrl },
  });

  notification.addEventListener("click", () => {
    window.focus();
    window.location.href = notification.data.trackingUrl;
  });
}

5. Service worker integration for background push

The Notification constructor only works while the tab is open. For notifications that arrive even when the browser is completely closed, combining the Notifications API with the Push API and a service worker is necessary. The server sends a push message through the browser's push service, the service worker receives it in the push event, and displays the actual notification through self.registration.showNotification().

This separation explains why new Notification() from the main thread and registration.showNotification() from the service worker are two distinct but related APIs that produce the same visual notification. For production push implementations of the Notifications API, the service worker path is practically always relevant, because it works independently of the tab's lifecycle.


// Inside the service worker: react to a push message from the server
self.addEventListener("push", (event) => {
  const payload = event.data ? event.data.json() : {};

  event.waitUntil(
    self.registration.showNotification(payload.title || "New message", {
      body: payload.body,
      icon: "/images/icon-192.png",
      badge: "/images/badge-72.png",
      tag: payload.tag,
      data: payload.data,
      actions: [
        { action: "open", title: "Open" },
        { action: "dismiss", title: "Dismiss" },
      ],
    })
  );
});

6. Handling clicks and actions

When a user clicks a notification shown by a service worker, no tab is automatically opened or focused, this behavior must be explicitly implemented in the service worker's notificationclick event. The method clients.matchAll() returns every open tab of the same origin, so an already open tab can be focused instead of unnecessarily opening a new one.

In addition, the Notifications API combined with service workers supports named action buttons through the actions array, as shown in the previous code example. Each action has its own action ID, which can be distinguished in the notificationclick event through event.action, for example to differentiate "Open" from "Mark as read" without having to open the application at all.


// Inside the service worker: handle notification clicks and actions
self.addEventListener("notificationclick", (event) => {
  event.notification.close();

  if (event.action === "dismiss") {
    return; // Just close, no navigation needed
  }

  const targetUrl = event.notification.data?.url || "/";

  event.waitUntil(
    clients.matchAll({ type: "window" }).then((clientList) => {
      for (const client of clientList) {
        if (client.url === targetUrl && "focus" in client) {
          return client.focus();
        }
      }
      return clients.openWindow(targetUrl);
    })
  );
});

7. Controlling frequency and relevance

Even with perfect timing on the initial permission request, the Notifications API can annoy users if notifications appear too frequently or without real value. A user who once agreed can revoke permission at any time through the browser settings, and that is exactly what often happens when applications send every marketing action as a notification instead of only relevant, time critical events.

A proven practice is maintaining a clear server side categorization of which events actually justify a push notification, such as shipping confirmations or incoming chat messages, while less urgent information like newsletter updates stays on other channels such as email. The Notifications API only works long term if users perceive the channel as reliably important rather than as a source of spam.

8. Platform differences: desktop, Android, iOS

The Notifications API behaves with varying reliability depending on the platform. On desktop Chrome, Edge and Firefox it works fully and reliably, including push notifications while the browser is closed through the operating system's background process. On Android Chrome, support is likewise excellent and deeply integrated into the operating system.

On iOS the situation is more complicated: Safari has only supported web push notifications since iOS 16.4, and exclusively for progressive web apps the user has explicitly installed through "Add to Home Screen". Plain Safari tab usage without a PWA installation still does not support the Notifications API on iOS. Applications with a significant iOS user base should explicitly account for this limitation in their onboarding communication and offer alternative channels such as email as a fallback.

9. Notifications API compared to other engagement channels

The Notifications API is one channel among several, each with its own strengths and limitations.

Channel Reachability Delivery rate Typical use
Notifications API (Web Push) Even with tab closed, iOS only as a PWA High, once opted in Time critical events, chat
Email Always reachable Low, often in the spam folder Non time critical updates
In app banner Only while the application is open Very high during active use Feature hints, onboarding
SMS Very high, even without internet Very high Critical security alerts

In practice, these channels complement each other. The Notifications API is excellent for time critical but not security critical events for already engaged users, while email serves as a reliable fallback for every user, independent of browser permissions or PWA installation.

Mironsoft

JavaScript development, browser APIs and modern web applications

Low opt-in rate on push notifications?

We rework your permission flow, integrate service worker push cleanly, and make sure the Notifications API is perceived as a helpful feature instead of an annoying dialog.

Permission flow

Two step opt-in with context instead of an immediate browser dialog

Push integration

Service worker, Push API and notification actions wired up cleanly

Platform strategy

iOS PWA limitations and email fallback considered from the start

10. Summary

The Notifications API itself is straightforward, its bad reputation almost always stems from wrong timing and missing context in the permission request. A two step opt-in flow with a custom, explanatory hint before the native browser dialog, combined with clean service worker integration for background push, turns the API into a feature users actively appreciate instead of dismissing on sight.

Anyone using the Notifications API in production should consistently monitor the frequency and relevance of notifications, account for the platform differences between desktop, Android and iOS, and always keep a fallback channel such as email ready for users without granted permission. This combination of technical cleanliness and thoughtful timing decides the difference between a valued feature and an annoyed, dismissed dialog.

Implementing the Notifications API Correctly — Key Takeaways

Two step opt-in

Show a custom explanatory hint first, only then call Notification.requestPermission() from a user gesture.

Service worker push

For notifications with a closed tab, combining with the Push API and showNotification() is essential.

tag and actions

tag prevents duplicates, actions enable direct interactions without having to open the application.

Platform fallback

iOS requires PWA installation, email should always stand by as a reliable second channel.

11. FAQ: Implementing the Notifications API Correctly

1What is the Notifications API?
System notifications outside the browser window, via Notification.requestPermission and the Notification constructor.
2Don't trigger dialog immediately?
Without context most reject it, a custom hint beforehand raises the opt-in rate.
3Ask again after rejection?
No, requestPermission returns denied immediately after rejection, no new dialog.
4What does tag do?
Replaces instead of stacking, useful for frequently updated content.
5Service worker needed?
Yes, for notifications with the browser closed via the Push API.
6Handle clicks?
In the notificationclick event with clients.matchAll or clients.openWindow.
7Does iOS support it?
Only from iOS 16.4 and only as an installed PWA.
8How often to send?
Only for genuinely relevant events, otherwise permission gets revoked.
9What are actions?
Named buttons in the notification, distinguishable via event.action.
10Fallback channel?
Email as a reliable second channel, independent of permission or platform.