EmpireUI
Get Pro
← Blog7 min read#push-notifications#service-worker#react

Push Notifications in React: Service Worker, Permission Flow

Learn how to wire up push notifications in React apps: service worker registration, permission prompts, VAPID keys, and handling edge cases that tutorials skip.

Developer laptop showing browser notification prompt on a dark terminal screen

Why Push Notifications Are Harder Than They Look

Honestly, push notifications look trivial until you're 4 hours deep, staring at a DOMException you've never seen before, wondering why Chrome and Firefox handle the same permission flow completely differently. Every "how to add push notifications in 5 minutes" tutorial skips the parts that actually break in production.

Here's what's actually going on under the hood. You've got three distinct moving pieces: the browser's Push API, the Web Notifications API, and a service worker acting as the glue. They're separate specs. They fail independently. And they each have their own quirks around HTTPS, user gesture requirements, and browser support gaps.

This article walks through the real flow — service worker setup, permission prompts, VAPID-based server push, and the annoying edge cases. No hand-waving. No skipping the hard bits.

Registering a Service Worker in a React App

First, you need a service worker file. In a Vite-based React project, drop sw.js (or service-worker.ts if you're using workbox) into the public/ folder so it's served at the root scope. The scope matters — a service worker at /app/sw.js only controls pages under /app/, which is usually not what you want.

Register it early — typically in main.tsx after the initial render, or in a useEffect inside your root App component. Don't register it on every render. Check 'serviceWorker' in navigator first because Safari below version 16.4 will just silently fail otherwise.

// src/hooks/useServiceWorker.ts
import { useEffect, useState } from 'react';

export function useServiceWorker() {
  const [registration, setRegistration] =
    useState<ServiceWorkerRegistration | null>(null);

  useEffect(() => {
    if (!('serviceWorker' in navigator)) return;

    navigator.serviceWorker
      .register('/sw.js', { scope: '/' })
      .then((reg) => {
        console.log('SW registered, scope:', reg.scope);
        setRegistration(reg);
      })
      .catch((err) => {
        console.error('SW registration failed:', err);
      });
  }, []);

  return registration;
}

The hook returns the ServiceWorkerRegistration object you'll need for the subscription step. Hold onto it — you'll pass it down or put it in context depending on your app's structure.

Handling the Permission Prompt Without Annoying Users

The single biggest mistake dev teams make: calling Notification.requestPermission() on page load. That's how you get 95% of users clicking "Block" before they've even understood what your app does. Permission prompts need user context — trigger them after a meaningful action, like a user explicitly turning on notifications in their settings page.

Also worth knowing: requestPermission() originally took a callback. The spec updated it to return a Promise. Some older browsers (Safari < 15) only support the callback form. The safe pattern handles both:

// src/utils/requestNotificationPermission.ts
export async function requestNotificationPermission(): Promise<NotificationPermission> {
  if (!('Notification' in window)) {
    return 'denied';
  }

  if (Notification.permission !== 'default') {
    return Notification.permission;
  }

  // Handle old callback-only implementations
  return new Promise((resolve) => {
    const result = Notification.requestPermission((status) => resolve(status));
    // Modern browsers also return a Promise
    if (result) result.then(resolve);
  });
}

Once you have 'granted', you can call registration.pushManager.subscribe(). If you get 'denied', don't pester the user. Show a small info message explaining they can re-enable in browser settings, then move on. You can check this against toast notifications in React — a toast is often the right UI pattern for surfacing the blocked state without being intrusive.

VAPID Keys and Subscribing to Push

To send server-side push, you need VAPID (Voluntary Application Server Identification) keys. Generate them once and store them. On the server side, npx web-push generate-vapid-keys gives you a public/private pair. The private key never leaves your server. The public key goes into your frontend.

The subscription itself requires converting your base64 VAPID public key to a Uint8Array before passing it to pushManager.subscribe(). This trips up a lot of people — the API doesn't accept the raw base64 string directly.

// src/utils/subscribeToPush.ts
function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}

export async function subscribeToPush(
  registration: ServiceWorkerRegistration,
  vapidPublicKey: string
): Promise<PushSubscription | null> {
  try {
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true, // required — can't do silent pushes in Chrome
      applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
    });
    return subscription;
  } catch (err) {
    console.error('Push subscription failed:', err);
    return null;
  }
}

The userVisibleOnly: true flag is mandatory. Chrome won't allow silent background push without it, and you'll get a DOMException: Registration failed - missing applicationServerKey if the subscription call is made without VAPID keys at all. After subscribing, POST the subscription object to your backend so you can target this user later.

Writing the Service Worker Push Handler

Your public/sw.js needs to listen for the push event and show a notification. The browser will kill the service worker shortly after the push fires, so you've got to call event.waitUntil() around any async work — otherwise the SW shuts down before showNotification completes.

// public/sw.js
self.addEventListener('push', (event) => {
  if (!event.data) return;

  const data = event.data.json();

  const options = {
    body: data.body,
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    data: { url: data.url ?? '/' },
    actions: [
      { action: 'open', title: 'View' },
      { action: 'dismiss', title: 'Dismiss' },
    ],
  };

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

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'dismiss') return;

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

The notificationclick handler tries to focus an existing window first before opening a new one. Users hate when clicking a notification opens a 4th tab of the same app. This pattern checks all open clients and focuses the right one if it's already open.

Sending Push from Your Backend

On the Node.js side, the web-push npm package handles the crypto and protocol details. You store the PushSubscription objects in your database and send to them when events occur. Think of it like storing an email address — except these subscriptions expire and users can revoke them.

When a subscription expires or becomes invalid, the push endpoint returns a 410 Gone status. Your backend needs to handle this by deleting the stale subscription from your DB. Ignore it and you'll build up a pile of dead subscriptions that waste API calls.

// server/push.js (Node.js)
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:hello@yourapp.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

export async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload)
    );
  } catch (err) {
    if (err.statusCode === 410) {
      // Subscription expired — remove from DB
      await db.pushSubscriptions.delete({ endpoint: subscription.endpoint });
    } else {
      throw err;
    }
  }
}

Keep the payload under 4KB. The Web Push protocol doesn't technically have a hard limit, but in practice Firefox caps payloads around 4096 bytes and Chrome follows GCM/FCM limits. For larger data, send a minimal notification and let the service worker fetch the full content from your API.

TypeScript Types and Common Gotchas

If you're using TypeScript (and you should be — check out React TypeScript tips for patterns that actually save time), the Push API types live in lib.dom.d.ts. They're included in TypeScript 4.9+ but you might need to add "lib": ["DOM", "DOM.Iterable"] to your tsconfig.json if you see PushSubscription is not defined.

A few gotchas that aren't obvious. First: the subscription's endpoint URL is the push service URL, not yours. Don't expose it to the user or log it carelessly — it's essentially a capability token. Second: pushManager.getSubscription() lets you check if the user is already subscribed without prompting for permissions again. Use it on mount so you don't show a "turn on notifications" button to users who already enabled them.

What happens when a user clears site data? The service worker gets unregistered, the subscription is gone, but your DB still has the old subscription. That's why you should always try to re-subscribe on app load if permission is 'granted' and verify the subscription endpoint matches what you have stored. It's a bit of housekeeping, but it saves you from silently failing pushes for users who reinstalled or cleared storage.

Putting It Together in a React Component

Here's how all the pieces connect in a real settings panel. The user gets a toggle — it checks current permission state, subscribes or unsubscribes accordingly, and syncs with the backend. You'll probably want to pair this with a theme toggle in React if you're building a settings screen, since both need the same local-state-plus-server-sync pattern.

The permission flow for push notifications also pairs naturally with in-app toast notifications — use toasts to confirm when push is enabled or to surface errors when something goes wrong during the subscription flow. Users need feedback immediately after taking an action, and a toast at the bottom of the screen is far less disruptive than a modal.

One last thing worth calling out: test on real devices. The permission UX in Chrome on Android is very different from Chrome on macOS. Firefox Mobile handles re-subscribing after permission revocation differently. iOS Safari (16.4+) finally added push support but only through installed PWAs — a standalone browser tab won't receive push. Budget time for cross-browser testing. It's not glamorous, but it's where the real integration work lives.

FAQ

Why does `Notification.requestPermission()` return undefined in some browsers?

Older Safari versions (below 15) only implemented the callback-based version of requestPermission, not the Promise-based one. The function returns undefined instead of a Promise in those environments. The fix is to wrap it in a Promise manually and handle both the return value and the callback argument — the code example in the permission section above covers this pattern.

What is `userVisibleOnly: true` and can I set it to false?

The userVisibleOnly flag means every push message must result in a visible notification — you can't do silent background syncs with it. Chrome requires this to be true and will throw a DOMException if you try to subscribe with false. Firefox allows false in theory but the spec intends this to eventually enforce visible notifications everywhere. Design your architecture assuming every push will show a notification.

How do I generate VAPID keys and where do I store them?

Run npx web-push generate-vapid-keys once. You get a public/private pair. The public key goes in your React app as an environment variable (e.g., VITE_VAPID_PUBLIC_KEY). The private key stays on your server as VAPID_PRIVATE_KEY — never commit it, never send it to the client. Regenerating VAPID keys invalidates all existing subscriptions, so treat them like long-lived API credentials.

Why are my push notifications not showing up even though the subscription succeeded?

Three common reasons: (1) Your service worker isn't active yet — there's a lifecycle where it installs, waits, then activates. Force activation with self.skipWaiting() in the SW install handler during development. (2) The event.waitUntil() call is missing, so the SW shuts down before showNotification() resolves. (3) Notifications are blocked at the OS level on the test device, separate from the browser-level permission.

Does push notification work in Safari on iOS?

Yes, but only since Safari 16.4 and only for Progressive Web Apps added to the Home Screen — not for regular browser tabs. The user has to explicitly add your site to their Home Screen first. Test your PWA manifest and ensure your site passes the install criteria. Regular in-browser push in Safari on iOS is not supported as of late 2026.

How do I unsubscribe a user from push notifications?

Call registration.pushManager.getSubscription() to get the current subscription, then call subscription.unsubscribe() on it. This removes the subscription in the browser. You also need to DELETE it from your backend database by sending the endpoint URL so your server stops trying to push to a dead subscription. Always do both sides — browser-side unsubscribe alone leaves stale records on your server.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Offline-First React Apps: IndexedDB, SyncManager, PWAProgressive Web Apps with React and Next.js in 2026Workbox Service Worker in React: Caching Strategies for PWAStepper Progress Form: Linear Flow with Validation