DEV Community

Cover image for Push notifications in a PWA — the flow, the VAPID keys, and the iOS asterisk (FieldKit #6)
Oleksandr Trukhnii
Oleksandr Trukhnii

Posted on

Push notifications in a PWA — the flow, the VAPID keys, and the iOS asterisk (FieldKit #6)

This is part 6 of FieldKit, a series where I build one real Progressive Web App and use it to dig into what modern PWAs can actually do. FieldKit is a field-notes app — open source (on GitHub). It already works offline, installs, captures media, geotags notes, and imports/exports/shares. Now the capability people are most surprised the web even has: push notifications that arrive when the app is closed.

The one that sounds impossible

"Websites can't send notifications when they're closed." That was true for years, and it's why push feels like the most native-only capability of all. But a PWA absolutely can — a reminder to review today's field notes can land on your lock screen with FieldKit nowhere in sight.

The catch is that push is the most moving-parts capability in this series. Before any code, get the mental model straight, because it trips everyone up:

  1. Your app asks the user for notification permission.
  2. Your app subscribes to push and gets a PushSubscription — an endpoint URL plus keys, unique to this device and browser.
  3. You send that subscription to your server.
  4. Your server, when it wants to notify, signs a message with a VAPID key and POSTs it to that endpoint (which belongs to the browser's push service — Google's FCM, Mozilla's, Apple's).
  5. The push service wakes the user's service worker with a push event, even with no tab open.
  6. The service worker shows the notification.

The important realisation: you never talk to the device directly. You hand a subscription to a push service and it does delivery. Let's build the browser side, then be honest about the server side.

Step 1: permission (and don't be sleazy about it)

Notifications need explicit permission, requested from a user gesture:

export async function enableNotifications() {
  if (!("Notification" in window)) {
    throw new Error("Notifications aren't supported in this browser.");
  }
  const permission = await Notification.requestPermission();
  if (permission !== "granted") {
    throw new Error(`Notification permission ${permission}.`);
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

One UX rule worth more than any code here: never call this on page load. A permission prompt the instant someone arrives is the fastest way to a permanent "Block" — and once blocked, you can't ask again. Tie it to a clear action ("Enable reminders"), like FieldKit's 🔔 button.

Step 2: subscribe, with a VAPID key

A subscription ties this device to your server's identity via VAPID (Voluntary Application Server Identification) — a public/private key pair. The public half goes in the browser; the private half stays on your server and signs outgoing pushes.

export async function subscribeToPush() {
  const reg = await navigator.serviceWorker.ready;
  let sub = await reg.pushManager.getSubscription();
  if (!sub) {
    sub = await reg.pushManager.subscribe({
      userVisibleOnly: true, // you must show a notification for every push
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
    });
  }
  // await fetch("/api/subscribe", { method: "POST", body: JSON.stringify(sub) });
  return sub;
}
Enter fullscreen mode Exit fullscreen mode

Two things bite people here. userVisibleOnly: true is mandatory — browsers refuse "silent" push; every push must result in a visible notification. And applicationServerKey must be a Uint8Array, but VAPID keys are distributed as URL-safe base64, so you convert:

function urlBase64ToUint8Array(base64String) {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  const raw = atob(base64);
  const output = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i);
  return output;
}
Enter fullscreen mode Exit fullscreen mode

Generate your own keys once with npx web-push generate-vapid-keys and keep the private one secret.

Step 3: handle the push in the service worker

This is the payoff — code that runs with no page open. The push service wakes the worker with a push event; you show a notification in response:

self.addEventListener("push", (event) => {
  let data = { title: "FieldKit", body: "You have a new update.", url: "/" };
  try {
    if (event.data) data = { ...data, ...event.data.json() };
  } catch {
    if (event.data) data.body = event.data.text();
  }
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: "/icons/icon-192.png",
      badge: "/icons/icon-192.png",
      data: data.url,
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

event.waitUntil() is not optional: it keeps the worker alive until the notification is shown. Skip it and the browser may kill the worker mid-push.

And a notification nobody can tap is useless, so handle the click — focus an existing window or open a new one:

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = event.notification.data || "/";
  event.waitUntil(
    (async () => {
      const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
      const open = all.find((c) => c.url.startsWith(self.location.origin));
      if (open) return open.focus();
      return self.clients.openWindow(target);
    })()
  );
});
Enter fullscreen mode Exit fullscreen mode

About the server (and what FieldKit actually ships)

Everything above is the browser side, and it's complete. The missing half is a server that stores subscriptions and sends pushes — typically Node with the web-push library, which handles VAPID signing and the POST to the push endpoint for you:

// server-side sketch (Node + web-push)
webpush.setVapidDetails("mailto:you@example.com", PUBLIC_KEY, PRIVATE_KEY);
await webpush.sendNotification(subscription, JSON.stringify({ title: "Review your notes", url: "/" }));
Enter fullscreen mode Exit fullscreen mode

FieldKit is a static, backend-less app, so it doesn't run a push server. What it does ship — and what works right now, offline, from the 🔔 button — is a local notification via the same service-worker mechanism:

export async function showLocalNotification(title, body) {
  const reg = await navigator.serviceWorker.ready;
  await reg.showNotification(title, { body, icon: "/icons/icon-192.png", tag: "fieldkit-reminder" });
}
Enter fullscreen mode Exit fullscreen mode

That's genuinely useful on its own (local reminders), and it exercises the exact permission + showNotification path that a real push uses — only the "who triggers it" differs. If you add a server later, the browser code here doesn't change.

Honest support picture

  • Notification API + service-worker showNotification: broad, including Chromium, Firefox, and Safari (desktop).
  • Push API (PushManager, VAPID): Chromium and Firefox for a while; Safari on macOS since 16.
  • iOS — the big asterisk: web push works on iPhone/iPad only since iOS 16.4, and only for a PWA the user has installed to the Home Screen (remember part 2). A site open in a Safari tab gets nothing. This is the reason installability isn't a nice-to-have if you care about push on iOS — it's a prerequisite.
  • Every entry point needs a secure context and a user gesture, and userVisibleOnly: true is required.

Check caniuse: Push API and Notifications before promising delivery on any given platform.

How this compares to Electron

  • Electron shows OS notifications trivially via its Notification API, and because it runs a persistent desktop process, it can notify on its own schedule without any push service at all — no VAPID, no endpoints, no server round-trip. For desktop, it's simpler. But it's desktop-only, and "notify a user who doesn't have the app running" still means you keep a process alive or build your own background mechanism.
  • The PWA carries more ceremony — permission, subscription, VAPID, a server, a third-party push service — but buys something Electron can't: notifications delivered to a phone the user carries, with the app fully closed, through the platform's own battery-friendly push infrastructure. That's the entire point of push, and it's exactly where a field tool needs it.

Put simply: Electron makes desktop notifications easy; the PWA makes mobile, app-closed notifications possible at all — at the cost of a genuinely more involved setup.

Try it

Serve FieldKit over localhost, tap 🔔, and grant permission — you'll get a confirmation notification straight from the service worker (works offline).

FiedlKit notifications

To see real push, generate VAPID keys, drop a tiny web-push server in front of the subscription, and the SW push handler above will light up. On iPhone, install FieldKit to the Home Screen first, or push won't arrive at all.

git clone https://github.com/JohnJunior/FieldKit.git
cd FieldKit
npx serve .
Enter fullscreen mode Exit fullscreen mode

Next up (FieldKit #7): the finale — native-grade trust, with passkeys/WebAuthn for biometric sign-in and a look at the Payment Request API (and its honest limitations).

Top comments (0)