PWA Push Notifcations for web3 apps

The successful launch of friend.tech instigated a new meta in the web3 discourse about Progressive Web Apps, with many commenters heralding the approach as the next big thing in crypto consumer applications. PWAs are not a new concept, but recent changes in the Apple ecosystem, combined with app store rules restricting the sale of virtual goods in native apps, have helped create a new opportunity for web3 apps that would benefit from native app features but would likely not be approved by app store reviewers. In particular, Apple's support for PWA push notifications, added in iOS 16, makes PWAs a viable alternative to native applications that would benefit from reengaging users with mobile push.

I am no expert in any of this. I recently added push to my web3 project NFTA 📈 and found resources to be spread out and not very direct. You might have better luck just asking ChatGPT to walk you through this, that's pretty much what I did. I have shipped multiple native apps to both the App Store and Google Play Store, so I have some experience to compare web push to native push, and can also add some advice with regard to app icons and splash screens.

The goal here is to provide a straightforward resource for implementing push in a PWA. We'll focus on iOS - I truly don't know who would ever want to get push notifications from a website on their computer, and I don't have an Android so to be perfectly honest I am just assuming this works on Android as well.

The other challenging aspect with PWAs and Apple devices is the install process. We'll address this briefly as we expect Safari 17 to add better support for installing PWAs directly, rather than making users jump through the share -> Add To Home Screen hoops.

If you find this valuable I'd really appreciate your support on NFTA - mint your predicted price chart as an onchain NFT and the best prediction wins the prize pool! https://nfta.pl

Bare Minimum

So let's be honest - a PWA is a website. Really, that's it. Any website can already be saved to an iPhone's home screen as a bookmark. If users are asking you for an app, it's entirely possible they just want easier access to it on their phone rather than typing your address every time. Maybe you can just write a post in your knowledge base or send an email to your users instructing them how to do this. Of course users can also bookmark websites on their computer, if they don't know how to do that man I don't know what to say.

To go a step further, you can add a site.webmanifest JSON file to your webapp and reference it in a metatag. You can also add images for the icon to be used when a user installs your app on mobile devices and customize the name (otherwise the title and favicon get used, might be fine for you).

Once you add the manifest file and reference it, some browsers start to show an install button when users visit your site. For instance, here's friend.tech on Chrome on a Macbook:

Screenshot of Chrome browser PWA install dialog
Screenshot of Chrome browser PWA install dialog
Screenshot of NFTA PWA
Screenshot of NFTA PWA

So OK, that's the bare minimum to get this working - add a site.webmanifest and reference it in a metatag:

<head>
  <link rel="manifest" href="/site.webmanifest" />
</head>

Then stick something like this in your public folder:

{
  "name": "NFTA",
  "short_name": "NFTA",
  "start_url": "/",
  "icons": [
    {
      "src": "/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "theme_color": "#e8f4fc",
  "background_color": "#e8f4fc",
  "display": "standalone"
}

MDN and Google both have comprehensive documentation of the available properties here. I also highly recommend Real Favicon Generator for all of your favicon and PWA icon needs. Upload an image, and that app will walk you through the various image needs for favicons and PWA icons, plus generate all the metatag HTML you need to properly reference them.

Install Flow Polish

Before we jump into push notifications, lets look at PWA installation flows and the beforeinstallprompt event. We can use this event to determine whether the user's browser supports PWA installation programmatically. If so, we can add a button to our page that performs the same function as clicking on the URL icon we saw above in the friend.tech example.

What's a bit odd is that some browsers will let you request notifications without installing the PWA. And Apple devices do not support this install flow, though it seems that may change soon per some interpretations of Safari 17 beta release notes. But if you want to send Apple iDevice users push notifications, they must install your PWA.

I used a library react-pwa-install to get my bearings here, ultimately forking the library to have a bit more control over UI. But the general gist is that if the beforeinstallprompt event fires on page load, you know the browser supports programmatic installation, and you can add a button to your UI to prompt users to install. This library handles Apple devices gracefully, instructing the user on how to use the mobile Safari share sheet and Add To Home Screen button to install the PWA.

Again, depending on your needs, perhaps you don't need to implement this programmatically in UI. Maybe you can start with a blog post or FAQ or whatever to instruct users how to do this on each platform. And hopefully this becomes less relevant as users learn about this functionality in various apps, or even totally irrelevant as Apple adds programmatic support in newer versions of Safari. It seems promising they will - the EU antitrust actions against Apple seem to be spurring this.

Here's what our implementation looks like on Macbook Chrome and iOS Mobile Safari:

NFTA screenshot of programmatic install button
NFTA screenshot of programmatic install button
NFTA screenshot of iOS PWA install instructions
NFTA screenshot of iOS PWA install instructions

Push Notifications

Ok lets get into the meat of this tutorial. We'll focus on mobile users going forward, and assume that they've installed your PWA by book or by crook. Now we want to re-engage them at our command, sending vibrations into their pockets. Of course you need to be judicious about this - sending too many pushes will annoy your users, so you should give them granular control over events for which they wish to receive notifications.

Requirements

In order to send push notifications, you're gonna need a back end of some sort. I had hoped we could do something similar to iOS's native local notifications using cron or JS intervals or timeouts, but no such luck. PWA push notifications are handled by service workers, and a service worker cannot live indefinitely (the browser can kill it). They rely on events to wake up and perform tasks and have no internal clock or capability to schedule tasks independent of the browser's events.

Your back end will be responsible for persisting data about push subscriptions and sending the push notifications themselves. This back end could be a SAAS like Firebase, but compared to implementing native app push notifications I found the web push API to be a lot simpler to handle myself. In a native app I would use a 3rd party service for this, but this time around it seemed much easier to handle it all from my own NextJS app with a postgres DB and some edge functions run on cron with Vercel.

VAPID security

VAPID (Voluntary Application Server Identification for Web Push) is part of the Web Push API standard and is a private/public key scheme that securely identifies your server as being able to send a push notification to a user's device. If you've done iOS native push, VAPID replaces APNS and all the certificate generation and profile generation you need to walk through to send notifications securely.

You generate a key pair one time and store the public and private keys. When you prompt a user for push permission, you use the public key. The generated Subscription object includes a URL you can use to send a push to that browser, authenticated by use of your private key on your server. Sounds complex, it's not, libraries handle all of this for you, but the rationale is that this handshake prevents other applications from using the push subscription to send your users notifications.

The NodeJS library web-push has functions for VAPID key generation and will also handle sending push notifications. If you're building a backend in another language I assume there are similar libraries.

Here's how to generate your keys in a little node script after installing the package. You only need to do this once, and can store the keys in ENV variables. Be sure to keep the private key private on your server, while the public key can be exposed to your client:

import webpush from "web-push";

function main() {
  const vapidKeys = webpush.generateVAPIDKeys();
  // Prints the PublicKey and PrivateKey to the console.
  console.log(vapidKeys);
}

main()

If you lose or destroy these keys, anyone who as opted into push will need to re-accept. So don't lose these!!

Service Worker

PWA push is handled by a service worker, which MDN describes as

...an event-driven worker registered against an origin and a path. It takes the form of a JavaScript file that can control the web-page/site that it is associated with, intercepting and modifying navigation and resource requests, and caching resources in a very granular fashion to give you complete control over how your app behaves in certain situations (the most obvious one being when the network is not available).

Service workers can do a lot of things, we'll stay laser focused on using them for push notifications. There's 2 steps here

  1. create the service worker and expose it to your frontend

  2. register your service worker when a user loads your page

One thing to be aware of is that the service worker is a javascript file served from your public directory and consumed directly by the browser. I write most of my frontends with typescript these days, but unless you want to go through some hassle of building a typescript based service worker with tsc and exporting a JS version, you might as well just write this thing in javascript that any browser can understand. It's extremely simple, but you might need to update your eslint config to stop your editor from complaining.

Here's our full service-worker.js file from NFTA:

self.addEventListener('install', (event) => {
  self.skipWaiting();
});

self.addEventListener('push', (e) => {
  const body = e.data.text() || 'Push message has no payload';
  const options = {
    body,
        vibrate: [100, 50, 100],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1,
    },
   
  };
  e.waitUntil(self.registration.showNotification('Update', options));
});

The self.skipWaiting(); bit is an odd little bugger - my understanding is that, when first installed, the service worker waits for "activation" which will only happen on a full page load, which you're not gonna get in an SPA like a NextJS app. So we immediately skip waiting for activation and move on with our lives. You can read more about that here if you're interested. Again, I'm no expert, but my push notifications work and that's all I care about so I don't really know what to tell you!

We'll revisit the 'push' event listener, but let's stick this file in your public folder and look at how we register this worker. It's extremely simple:

  useEffect(() => {
    navigator.serviceWorker.register("/service-worker.js");
  }, []);

This code can only be executed by the browser, so in something like NextJS we're running this in a useEffect hook without dependencies. On page load, the service worker will be registered, and as part of registration will "skip waiting" and be ready to go.

Chrome dev tools make it easy to know that your service worker is registered. Bring em up and pop over to the "Application" tab. You should see something like this, with the green dot for Status, indicating the service worker is registered and activated:

Chrome dev tools screenshot of running service worker
Chrome dev tools screenshot of running service worker

Push permissions

With your service worker activated, we can move on to the part where we ask a user if they want to receive push notifications. Much like native app best practices, you should only prompt the user after a button click or some other engagement. I think mobile Safari actually prevents showing the permission popup unless attached to a user touch event, so you're forced into best practices even moreso than on native. Terrible native apps do that thing where when you first install it you get slammed with a bunch of requests - Push! Contacts! Ad Tracking! Don't do that.

The API we use here is the Notification API. We can ascertain the current permission status for the browser with Notification.granted, which returns a string of "default"|"granted"|"denied".

In unsupported browsers, the Notification class is undefined, so in our React component, we do something like this:

const [permission, setPermission] = useState("unsupported");

useEffect(() => {
  if (typeof Notification === "undefined") {
    return;
  }
  setGranted(Notification.permission);
}, []);

This way we server render as "unsupported", and once the client mounts we check the current browser permission status, and store that in React state if the Notification API is supported.

Then, in our render function, we conditionally render based on status. If we're already granted permissions, we can render something that helps us store granular push data. If we're in the "default" state, then we can render a button that asks for permission.

{granted === "unsupported" ? (
  <p>To get notifications, install the NFTA app below.</p>
) : null}
{granted === "default" ? (
  <Button label="grant notifications" onClick={register} />
) : null}
{granted === "granted" && address ? (
  // user has granted permission, show a component for DB
  // driven push preferences
  <Subscription address={address} onClose={onClose} />
) : null}

Here's what that click handler looks like. Note that we are using the VAPID public key we generated earlier as a param to the subscribe function call:

const register = async () => {
  Notification.requestPermission().then((permission) => {
    if (permission === "granted") {
      navigator.serviceWorker.ready.then((registration) => {
        registration.pushManager
          .subscribe({
            userVisibleOnly: true,
            applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBKEY,
          })
          .then((subscription) => {
            fetch("api/registerPush", {
              method: "POST",
              body: JSON.stringify({ subscription, address }),
            })
              .then(() => {
                setGranted(permission);
              })
              .catch((e) => {
                console.error(e);
              });
          });
      });
    }
  });
};

Tapping our button opens the device dialog for push notification permission. If the user accepts, our handler receives some data as a Subscription object. This is data we need to persist on our backend, so we submit a POST request to our API with that object and the current connected address.

Your application might need a more secure implementation of this! We are simply trusting that the address being submitted by the client is owned by the user. This might not be true, and you might need to implement better auth controls, like Sign in With Ethereum type things, to prove to your server that the user owns the address being submitted. In our case, there's no sensitive data included in push notifications, so if "hackers" want to get push notifications for Vitalik's address its totally fine.

Subscription storage

In order to later send notifications to this device, we need to store the Subscription object. We're also storing the Ethereum address so we can send notifications to devices we associate with that address. For our usecase, we offer to send a push when a user is on the leaderboard, so we iterate over our leaderboard addresses and send pushes to any address that registered.

We use Vercel postgres to store this data. We slam the whole Subscription object into a JSONB column, alongside an address column and some columns for our granular push opt-in. Users might want notifications for 1 address on multiple devices, so we put a unique constraint on Subscription.endpoint rather than the address:

CREATE TABLE Subscriptions
(
    id SERIAL PRIMARY KEY,
    address VARCHAR(64) NOT NULL,
    subscription JSONB NOT NULL,
    notification_game_starts_in_one_hour BOOLEAN DEFAULT FALSE NOT NULL
);

CREATE UNIQUE INDEX subscriptions_endpoint_idx ON Subscriptions ((subscription->>'endpoint'));

Here's what our API page route endpoint looks like for handling this POST request:

import { sql } from "@vercel/postgres";
import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  request: NextApiRequest,
  response: NextApiResponse
) {
  const { subscription, address } = JSON.parse(request.body);
  const notifyBeforeGame = true;

  await sql`INSERT INTO Subscriptions (address, subscription, notification_game_starts_in_one_hour) VALUES (${address}, ${JSON.stringify(
    subscription
  )}, ${notifyBeforeGame});`;

  return response.status(200).json({ status: "ok" });
}

Customize your subscription storage to your needs, and implement the controls necessary for updating this data. What we're showing here is not very secure...

Sending Notifications

Once you've stored the Subscription data, we're ready to send some push notifications! Depending on your application you may want to send these when a user receives a new message or their position is approaching liquidation. For our simple case, we will implement a cron based push that notifies our opted-in users 1 hour before a game starts.

This NextJS API page is run on a cron schedule and queries the Subscriptions table to find devices that have opted in to receive these notifications.

We don't really have to think about or deal with the Subscription object, we're just pulling the raw JSON out of the database and passing it aliong. We use our VAPID keys to authenticate against the endpoint webpush calls for each subscription, and of course add some content to the push notification itself. I've included some catch and console logging - this type of thing is always hard to get right on the first attempt, so check your server logs if you don't receive a notification and you'll have some info to help debug.

import webpush from "web-push";
import { NextApiResponse } from "next";
import { NextRequest } from "next/server";
import { sql } from "@vercel/postgres";

const message = "Next game starts in 1 hour - get your predictions in!";

async function main() {
  const result =
    await sql`SELECT * FROM subscriptions WHERE notification_game_starts_in_one_hour IS TRUE;`;

  const options = {
    TTL: 24 * 60 * 60,
    vapidDetails: {
      subject: "mailto:admin@www.nfta.pl",
      publicKey: process.env.NEXT_PUBLIC_VAPID_PUBKEY!,
      privateKey: process.env.VAPID_PRIVATE_KEY!,
    },
  };

  for await (const subscriber of result?.rows) {
    webpush
      .sendNotification(subscriber.subscription, message, options)
      .catch((err) => {
        console.log(err.statusCode); // Contains the HTTP Status Code
        console.log(err.body); // Contains the response body
      });
  }
}

export default async function handler(req: NextRequest, res: NextApiResponse) {
  await main();

  res.status(200).json({ message: "ok" });
}

I had some trouble with the subject option, no idea what its for. I'd just swap out our domain for yours. Remember I'm no expert here, just trying to share what's worked for me!

Handle in Service Worker

Let's return to our service worker - we don't need to make any changes, but we should review some aspects to understand how notifications get shown.

Here's our push event handler again which I copy pasted from somewhere. I assume the vibrate value is an Android thing. I wouldn't mess with the dateOfArrival or primaryKey. But you might want to play around with the body and the first arg to showNotification - iOS does a weird thing where the push notification display shows the 'Update' string (you can customize) followed by "from APP_NAME" and then the body text. So you might want to experiment some to make your notifications make sense to users.

self.addEventListener('push', (e) => {
  const body = e.data.text() || 'Push message has no payload';
  const options = {
    body,
    vibrate: [100, 50, 100],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1,
    },
   
  };
  e.waitUntil(self.registration.showNotification('Update', options));
});

I should also admit that I have not been able to get my Macbook Chrome browser to show anything? I don't know where notifications are supposed to show up or if they are supported. I'm not too worried about it, I don't think many users want desktop browser notifications, but if you figure it out let me know!

TLDR

1. Create service-worker.js and serve from public folder

self.addEventListener('install', (event) => {
  self.skipWaiting();
});

self.addEventListener('push', (e) => {
  const body = e.data.text() || 'Push message has no payload';
  const options = {
    body,
        vibrate: [100, 50, 100],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1,
    },
   
  };
  e.waitUntil(self.registration.showNotification('Update', options));
});

2. Register Service Worker

  useEffect(() => {
    navigator.serviceWorker.register("/service-worker.js");
  }, []);

3. Get Users to Install PWA

Can be programmatic, but for iOS you need to educate users to tap the Share button in browser, scroll down and tap Add to Home Screen.

4. Ask for push permission

const register = async () => {
    Notification.requestPermission().then((permission) => {
      if (permission === "granted") {
        navigator.serviceWorker.ready.then((registration) => {
          registration.pushManager
            .subscribe({
              userVisibleOnly: true,
              applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBKEY,
            })
            .then((subscription) => {
              fetch("api/registerPush", {
                method: "POST",
                body: JSON.stringify({ subscription, address }),
              })
                .then((resp) => resp.json())
                .then(() => {
                  setGranted(permission);
                })
                .catch((e) => {
                  console.error(e);
                });
            });
        });
      }
    });
  };

5. Store Subscription Data & Send Push

import webpush from "web-push";

import { sql } from "@vercel/postgres";
import { NextApiRequest, NextApiResponse } from "next";

const message = "You're set up with push notifications";

export default async function handler(
  request: NextApiRequest,
  response: NextApiResponse
) {
  const { subscription, address } = JSON.parse(request.body);
  const notifyBeforeGame = true;

  await sql`INSERT INTO Subscriptions (address, subscription, notification_game_starts_in_one_hour) VALUES (${address}, ${JSON.stringify(
    subscription
  )}, ${notifyBeforeGame});`;

 
  const options = {
    TTL: 24 * 60 * 60,
    vapidDetails: {
      subject: "mailto:admin@www.nfta.pl",
      publicKey: process.env.NEXT_PUBLIC_VAPID_PUBKEY!,
      privateKey: process.env.VAPID_PRIVATE_KEY!,
    },
  };

  await webpush
    .sendNotification(subscription, message, options)
    .catch((err) => {
      console.log(err.statusCode); // Contains the HTTP Status Code
      console.log(err.body); // Contains the response body
    });

  return response.status(200).json({ status: "ok" });
}

Thanks!

Thanks for reading, please be smart about security issues here and don't overburden with constant annoying push notifications.

Please let me know if you have any trouble, if this doesn't work for Android etc. I truly am not an expert in this, just felt like there weren't great straightforward resources, and I like to share what I learn!

If you appreciated this, NFTA could use your support! You can also find me on Twitter @sammybauch.

Subscribe to sammybauch.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.