Skip to main content
Being Idea Innovations
Browser Notifications in JavaScript: The Complete Guide (2026)
Back to Blog

Browser Notifications in JavaScript: The Complete Guide (2026)

23 February 20186 min readJavascript
Share:

Browser notifications let a web application put a message in front of a user even when they are looking at another tab. Used well, they bring people back to your app. Used badly, they are the single fastest way to get permanently blocked by both your users and their browser.

This guide covers the JavaScript Notification API end to end: how permissions actually work in 2026, how to show notifications from a service worker, how push notifications differ, and the platform limitations that will bite you.

Two different APIs, often confused

People say "browser notifications" to mean two separate things:

  • The Notification API displays a message from JavaScript running in an open page. If the tab is closed, nothing happens. This is the easy one.
  • The Push API lets your server wake a service worker and display a notification even when your site is not open at all. This needs a service worker, a push subscription and a backend.

Start with the Notification API. Add Push only when you genuinely need to reach users who have closed your site.

Requirements before anything works

Notifications only function in a secure context — HTTPS, or localhost during development. On plain HTTP the API is simply not there.

Always feature-detect rather than assuming:

function notificationsSupported() {
  return 'Notification' in window && window.isSecureContext;
}

Permission: the part that decides whether you succeed

Permission has three states, readable via Notification.permission:

  • "default" — never asked. You may prompt.
  • "granted" — allowed.
  • "denied" — blocked. This is effectively permanent. Calling requestPermission() again does nothing; the browser resolves it immediately as denied without showing anything. Only the user can undo it, buried in site settings, and virtually nobody does.

That asymmetry is the whole game. You get exactly one prompt, ever. So the cardinal rule is: never request permission on page load. A visitor who has been on your site for four seconds has no reason to say yes, and their "no" is forever.

Browsers now enforce this themselves. Chrome's "quieter notification permissions" replaces the prompt with a small address-bar icon for sites with historically low acceptance rates, and Firefox requires a user gesture before it will show the prompt at all. A permission request not triggered by a click may never appear.

Use a pre-prompt

Ask in your own UI first. If the user says no to your dialog, you have lost nothing and can ask again next week. Only when they say yes do you spend the one real prompt:

async function enableNotifications() {
  if (!notificationsSupported()) return { ok: false, reason: 'unsupported' };

  if (Notification.permission === 'granted') return { ok: true };
  if (Notification.permission === 'denied')  return { ok: false, reason: 'blocked' };

  // Must be called from a user gesture (a click handler), not on load.
  const result = await Notification.requestPermission();
  return result === 'granted'
    ? { ok: true }
    : { ok: false, reason: result };
}

document.getElementById('enable-btn')
  .addEventListener('click', async () => {
    const { ok, reason } = await enableNotifications();
    if (!ok && reason === 'blocked') {
      showHint('Notifications are blocked. Enable them in your browser site settings.');
    }
  });

Ask at the moment the value is obvious — after someone places an order and might want delivery updates, not the instant they land.

Showing a notification

function notify(title, options = {}) {
  if (Notification.permission !== 'granted') return null;

  const n = new Notification(title, {
    body: 'Your report finished generating.',
    icon: '/icons/notification-192.png',
    badge: '/icons/badge-72.png',   // monochrome, Android status bar
    tag: 'report-ready',            // replaces any existing notification with this tag
    renotify: false,                // re-alert when a tag is replaced
    requireInteraction: false,      // true = stays until dismissed (desktop only)
    silent: false,
    data: { url: '/reports/8412' }, // your own payload, read back on click
    ...options,
  });

  n.addEventListener('click', (event) => {
    event.preventDefault();
    window.focus();
    window.location.href = event.target.data.url;
    n.close();
  });

  return n;
}

The tag option is the one people miss. Without it, ten events produce ten stacked notifications. With a shared tag, each new one silently replaces the last — exactly what you want for something like an updating order status.

Service worker notifications — required on mobile

Here is a limitation that catches people out: the new Notification() constructor does not work on Android Chrome. It throws. On mobile you must display notifications through a service worker registration:

// Works on both desktop and mobile — prefer this everywhere.
async function notifyViaSW(title, options) {
  const reg = await navigator.serviceWorker.ready;
  await reg.showNotification(title, options);
}

Handle the click inside the service worker, where window does not exist:

// sw.js
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = event.notification.data?.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((list) => {
        // Focus an existing tab on that URL rather than opening a duplicate.
        for (const client of list) {
          if (client.url.includes(url) && 'focus' in client) return client.focus();
        }
        return clients.openWindow(url);
      })
  );
});

Wrapping the work in event.waitUntil() matters — without it the browser may terminate the service worker before your tab actually opens.

Push notifications: reaching closed tabs

Push requires a subscription tied to a VAPID key pair. Generate the keys once on your server; the public key goes to the client.

async function subscribeToPush(vapidPublicKey) {
  const reg = await navigator.serviceWorker.ready;

  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,  // required by Chrome — you must show something
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
  });

  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(sub),
  });
}

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((c) => c.charCodeAt(0)));
}

Then receive it in the service worker:

self.addEventListener('push', (event) => {
  const payload = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(payload.title ?? 'Update', {
      body: payload.body,
      icon: '/icons/notification-192.png',
      data: { url: payload.url },
    })
  );
});

Note userVisibleOnly: true. Chrome requires that every push you send results in a visible notification — silent background pushes for tracking are not permitted, and abusing this gets your push endpoint throttled.

On the server, use a library rather than implementing the encryption yourself: web-push for Node, pywebpush for Python, minishlink/web-push for PHP.

Platform limitations worth knowing

  • iOS Safari supports web push only from version 16.4 and only if the user has added your site to their Home Screen as a PWA. A regular Safari tab cannot receive push. You need a web app manifest with display: standalone, and the permission request must come from a genuine user gesture.
  • macOS and Windows route notifications through the OS notification centre, so Do Not Disturb and Focus modes will suppress them entirely. Never assume delivery.
  • requireInteraction is ignored on mobile.
  • Notification actions (buttons) work through showNotification only, never the constructor, and support varies.
  • Subscriptions expire. Handle pushsubscriptionchange and prune endpoints that return 404 or 410 from the push service, or you will slowly accumulate dead subscriptions.

Practical rules

  • Ask for permission after demonstrating value, from a click — never on load.
  • Pre-prompt in your own UI so a "no" is recoverable.
  • Use reg.showNotification() everywhere so mobile works.
  • Set a tag to collapse related updates.
  • Give users an off switch inside your app, not just in browser settings.
  • Send notifications people would miss if they stopped — order updates, mentions, finished jobs. Not marketing.

Get the permission moment right and the rest of the Notification API is straightforward. Get it wrong and no amount of good code will help, because you will never be asked again.

Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers