Last part of my push notifications series is integrating web push notifications.

In the first part I covered how to integrate push notifications on ios and android, then how to send them from a nestjs server, all using firebase cloud messaging.

You need to ask user for permission

In expo native I had to ask for user permission to send push notifications, and web is no different. Except my hook, it's adjusted for web calls:

const useNotificationPermission = () => {
  const [response, setResponse] = useState(null);
  const [isRequesting, setIsRequesting] = useState(false);

  const checkForNotification = async () => {
    if (!('Notification' in window)) {
      return 'denied';
    }
    try {
      setIsRequesting(true);
      return Notification.requestPermission();
    } catch (error) {
      console.error('Error checking notification permission:', error);
      return 'denied';
    } finally {
      setIsRequesting(false);
    }
  };

  useEffect(() => {
    checkForNotification()
      .then((status) => {
        console.log('Notification permission status:', status);
        setResponse(status);
      })
      .catch((err) => {
        console.error('Failed to check notification permission:', err);
      });
  }, []);

  return {
    status: response,
    isRequesting,
    request: checkForNotification,
  };
};

export default useNotificationPermission;

Once you load react native website, you should get this modal

show notifications permission popup

After the user has granted permission, you can request the fcm token. It is the address to which a push notification can be sent. And before you can request fcm token, get VAPID and configure service worker.

Get VAPID

Go to firebase console, project settings, cloud messaging and generate a key pair at the bottom. Save it in .env like "EXPO_PUBLIC_VAPID_TOKEN".

Create a service worker

Push notifications require a background context, and only way is with a service worker. You can put a worker in the /public/ directory. It's reserved for serving public assets, and reachable at http://localhost:3000/fsw.js. My firebase service worker:

importScripts('https://www.gstatic.com/firebasejs/12.2.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/12.2.1/firebase-messaging-compat.js');

self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim());
});
self.addEventListener('message', async (event) => {
  const replyBack = (message) => {
    if (event.ports && event.ports[0]) {
      event.ports[0].postMessage(message);
    }
  };

  if (event.data && event.data.type === 'INIT_FIREBASE') {
    if (firebase.apps.length) {
      console.log('Firebase already initialized in service worker');
      replyBack({ type: 'FIREBASE_INITIALIZED' });
      return;
    }

    const firebaseConfig = event.data.config;

    firebase.initializeApp(firebaseConfig);
    const messaging = firebase.messaging();

    messaging.onBackgroundMessage((payload) => {
      console.log('[firebase-messaging-sw.js] Received background message ', payload);
      const notificationTitle = payload.notification.title;
      const notificationOptions = {
        body: payload.notification.body,
      };

      self.registration.showNotification(notificationTitle, notificationOptions);
    });

    console.log('Firebase initialized in service worker');
    replyBack({ type: 'FIREBASE_INITIALIZED' });
  }
});

fsw.js

Service worker works in its own context in isolation, that's why I need to import firebase again, and initialize it.

You can exchange messages between the main thread and a worker. That's why I have an onMessage listener. This is my workaround to pass firebase configuration, because process.env won't work.

If you try to load your react native website, service worker should be registered.

And you should see all console logs from the worker. onBackgroundMessage listener is the one that catches notifications, and registration.showNotification shows them.

Service worker is registered on the client with

const swRegistration = await navigator.serviceWorker.register('/fsw.js');

I need to pass firebase configuration to the client and wait for the confirmation that it's initialized. That's why I have the await:

await navigator.serviceWorker.ready;
await new Promise<void>((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error('Service worker initialization timed out'));
      }, 5000);
      const channel = new MessageChannel();
      channel.port1.onmessage = (event) => {
        if (event.data && event.data.type === 'FIREBASE_INITIALIZED') {
          clearTimeout(timeout);
          if (event.data.error) {
            console.error('Service worker failed to initialize Firebase:', event.data.error);
            reject(new Error(event.data.error));
          } else {
            console.log('Service worker initialized Firebase successfully');
            resolve();
          }
        } else {
          console.error('Unexpected message from service worker:', event.data);
        }
      };

      swRegistration.active.postMessage({ type: 'INIT_FIREBASE', config: firebaseConfig }, [channel.port2]);
    });

First await is for the service worker registration, and the second one is for the firebase initialization from the worker. After that I can request the fcm token:

    return getToken(messaging(), {
      vapidKey: process.env.EXPO_PUBLIC_VAPID_KEY,
      serviceWorkerRegistration: swRegistration,
    });

Testing

I tried it with http://localhost:8081, I thought push notifications require https but it works with http too.

Load your website and copy the fcm token.

Go to firebase console, select Messaging, create a new campaign, fill title and text, click on "Send test message". Register your fcm token, firebase provides it when you request a token, log it in the console. Send message.

if you see your console.log("Received background message...") inside the service worker, that means the push is arriving.

Reasons why push notification popup is not showing up

If you don't see the push notification, maybe:

  • your website is in focus - click on another tab
  • do you have "do not disturb" on?
  • on MacOS, check that OS is not blocking push notifications from Chrome. In privacy -> notifications, find google chrome and allow notifications.
allow push notifications in macos
allow push notifications in macos for chrome

GitHub: https://github.com/amarjanica/firebase-expo-demo

Youtube: https://youtu.be/1Y3i9siqXak