Using Push Notifications in PWAs: The Complete Guide

featured article thumbnail

Progressive web apps (PWAs) allow brands to enjoy the advanced features of a mobile app without spending a lot of time developing it. With a PWA, you can deliver an app-like experience through a web browser, giving customers the sleek interface they expect while making the most of your development resources. To understand when PWAs are the right choice versus native app development, see our comprehensive comparison guide.

They enable businesses to re-engage users with personalized alerts and updates without requiring native app installation or app store distribution, combining offline functionality with real-time communication.

Web push is the only channel that survives a closed tab. It pairs a service worker subscription with a server that delivers payloads in the background, so the lifecycle is different from email or in-app messaging. Once you understand how the Push API works, the rest is just wiring.

Browser Support Check

Before we get to code, we'll confirm browser support. Service workers run in the background and keep working after the tab closes. Support is solid, at about 96% overall and 100% across evergreen browsers, as can be seen on the Can I use's service worker table.

Service worker support

The Push API (/PushManager) adds the subscription and delivery pieces. Evergreen browsers cover it as well, which you can verify in Can I use's Push API table. iOS Safari is listed as 'partial' because push notifications only work for Home Screen web apps, which we'll cover later in this post.

Push API support

Technical Overview of PWA Push Notifications

PWA bridge the gap between web-based and native applications, giving you the power to engage users like you would with a native app. At a high level, they use

  • Service Workers: Handle push notifications in the background, ensuring delivery even when the app is not open.
  • Web Push Protocols: Ensure secure data transmission and protect user data during the notification process.
  • Push Notification Servers: Implement notifications using tools like Firebase Cloud Messaging or other push notification platforms.

PWAs use service workers to send push messages to users. These service workers are scripts that run in the background, separate from a webpage. The script allows you to send alerts without user interaction or a web page.

A service worker listens for a push event to manage incoming notifications from a push service, ensuring they are properly routed and displayed to users, even if the application is not actively running.

In other words, PWA push notifications synchronize data in the background, allowing you to send push notifications even if the user hasn’t opened the PWA in their browser.

Getting started with Code

This guide assumes you have a working site to which we will add a service worker and manifest. If you do not, create a tiny static page in a public folder and serve it from localhost (which counts as a secure context).

<!-- public/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Push Demo</title>
    <link rel="manifest" href="/manifest.json" />
  </head>
  <body>
    <h1>Web Push Demo</h1>
    <button id="subscribe">Enable push</button>
    <script type="module" src="/main.js"></script>
  </body>
</html>

Serve the folder with a tiny static server:

npx serve public --listen 5173

Now that the page is running, we can register the service worker.

Register The Service Worker

Create a sw.js file in the public directory. Note that it must be served from the root, like https://example.com/sw.js (or http://localhost:3000/sw.js) and not https://example.com/something/sw.js. The root scope matters because the service worker only controls URLs under its own path. The filename can be changed to your liking.

// sw.js
self.addEventListener('install', () => {
  console.log('Service worker installed');
});

Service Workers aren't loaded (or imported) via the standard import statements or via <script src="..."> inclusions. Instead, it is installed by calling navigator.serviceWorker.register.

Register it from your app entry point (/main.js when using the sample HTML from above):

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    await navigator.serviceWorker.register('/sw.js');
  });
}

Confirm that your service worker is active via the "Application" tab in your browser dev tools. Note that install only runs on first install or when the file changes. You're not expected to see the log statement on every page refresh.

Subscribe With The Push API

Subscribing has two pieces: a user gesture on the page and a service worker ready to receive pushes. We already registered the service worker, so now wire the button.

But first, we need to generate VAPID keys. VAPID keys are the identity for your push server. Browsers use the public key to validate subscription requests, and push services (FCM, Mozilla Autopush, Apple Push Service) use the public key to verify the signature that your server creates with the private key. Use the generator below to create a pair.

VAPID Keys

This tool generates keys client-side using WebCrypto. Store keys securely in your backend for production use.

Next, we'll add a simple subscribe button to our HTML (the sample HTML above already includes it):

<!-- /index.html -->
<button id="subscribe">Enable push</button>

And wire it with a bit of JavaScript. We'll unpack it below the snippet.

// main.js
const button = document.querySelector('#subscribe');
const publicKey = 'YOUR_VAPID_PUBLIC_KEY';

const urlBase64ToUint8Array = (value) => {
  const padding = '='.repeat((4 - (value.length % 4)) % 4);
  const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
};

button.addEventListener('click', async () => {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(publicKey),
  });

  console.log('Push subscription:', subscription.toJSON());
});

A few things happen here: the button click triggers the browser permission prompt, pushManager.subscribe talks to the browser's push service (FCM, Mozilla Autopush, or Apple Push Service) and returns a subscription endpoint + keys tied to this browser.

The urlBase64ToUint8Array feels a bit magical at first, but applicationServerKey expects the VAPID public key as a Uint8Array. This helper converts a base64-encoded string to a Uint8Array.

We console.log the subscription data for now. Copy the endpoint and the keys object from the log so you can use them in the next step. In a real app, post the subscription to your backend and persist it so you can send pushes later.

Handle Push Events In The Service Worker

Now wire the service worker to show notifications. Service worker updates are only picked up after the browser detects a new file and installs it, and the new worker usually takes control after the next page load. If you do not see your changes, refresh twice or close and reopen the tab.

// sw.js
self.addEventListener('push', (event) => {
  const payload = event.data?.json() ?? { title: 'Update', body: 'Hello from web push' };

  event.waitUntil(
    self.registration.showNotification(payload.title, {
      body: payload.body,
      icon: '/icon-192.png',
      data: payload.url ?? '/',
    }),
  );
});

Send A Notification

A push message is just an HTTP request signed with your VAPID keys. Use any Web Push library on the server to sign the request and send it to the subscription endpoint you stored. There is no fixed web push payload schema: the JSON you send is your contract, and it shows up as event.data in the service worker. For a no-code test, paste the subscription JSON into the Web Push Sender below. Click the "JavaScript" tab if you prefer running it locally.

Did you forget to copy the subscription object? Retrieve it from your browser with the following snippet:

const [reg] = await navigator.serviceWorker.getRegistrations();
const sub = await reg.pushManager.getSubscription();
sub.toJSON();

Make It Installable

Most browsers let web push run without an install step. Safari on macOS works the same way once the user grants permission. Safari on iOS is different: push only works for Home Screen web apps, which is why the manifest and install flow matter.

Add a manifest.json file and link it from your HTML. If you want a quick generator, use the PWA manifest generator.

<link rel="manifest" href="/manifest.json" />
{
  "name": "My PWA",
  "short_name": "MyPWA",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0f172a",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Note that icons are required for installability. Make sure the files exist at the paths you list.

When you want to test on an iPhone, expose your local server over HTTPS with a tunnel:

npx localtunnel --port 5173

Localtunnel prints a public HTTPS URL that you can open on your device. Keep the npx serve process running so the tunnel can reach your local server.

Once you can install the PWA, subscribe, and see notifications arrive, the flow is complete. From there, store subscriptions in your backend and send payloads that your service worker turns into the right UI experience.

If you'd rather skip building the delivery plumbing, MagicBell helps you manage subscriptions, send web push notifications, and track delivery from one API. Create a free account at https://app.magicbell.com.

Debugging

Push notifications fail for boring reasons: insecure origins, misplaced service workers, client-signed tokens, and iOS flows that ask for permission before the app is installed. Service workers are scoped by their path, so /sw.js has to live at the root if you want full coverage. iOS is even stricter: WebKit's Web Push announcement makes it clear that web push is only for Home Screen web apps and permission prompts must be triggered by a user gesture.

Build it faster with MagicBell

Instead of building both the front-end & backend code, you can use MagicBell to laucnh PWA notifications quickly & effortlessly! You can use one of the buttons we offer:

  1. Web push button for React
  2. Web push button for Preact
  3. Web push button for Svelte

MagicBell React Web-Push Example

You can find the Web Push Notification starter repository on the MagicBell Github. This tutorial will be referencing code from the repository.

1. Enable the web-push channel in MagicBell

As a first step, configure the web-push channel in MagicBell.

2. Add the Service Worker

The service worker does most of the heavy lifting in push notifications for PWAs. To set this up, you’ll need to know JavaScript and understand how a push service establishes a communication channel for sending push notifications to users.

Create a public/sw.js file and add the following code:

importScripts('https://assets.magicbell.io/web-push-notifications/sw.js');

3. Add the MagicBell ContextProvider

MagicBell provides a ContextProvider component that you need to wrap your app with. The component provides the MagicBell context to all components in the SDK and allows you to use the WebPush Button in your app.

You will need to generate a User JWT on your server and pass it in the Context Provider.

We generate the User JWT in the app/auth.ts file, we use the use server directive to ensure that the user JWT generated on the server.

'use server';

import jwt from 'jsonwebtoken';

export async function getUserToken() {
  // Replace with your actual user ID or logic to retrieve it
  const userId = '7f4baab5-0c91-44e8-8b58-5ff849535174';

  const secret = process.env.MAGICBELL_SECRET_KEY!;
  const apiKey = process.env.MAGICBELL_API_KEY!;

  const payload = {
    user_email: null,
    user_external_id: userId,
    api_key: apiKey,
  };

  const token = jwt.sign(payload, secret, {
    algorithm: 'HS256',
    expiresIn: '1y',
  });

  return token;
}

In the app/layout.tsx file we wrap our app with the MagicBell provider and pass in the user token.

// ...
import Provider from "@magicbell/react/context-provider";

export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode; }>) {
  const userToken = await getUserToken();

  return (
    <Provider token={userToken}>
      <html lang="en">
        {/* ... */}
      </html>
    </Provider>
  );
}

Now we are ready to add the WebPushButton to our app!

4. Integrate the WebPushButton

Now that your app has MagicBell ContextProvider, you can start using the WebPushButton component.
Start by importing the WebPushButton component from the @magicbell/react package:

import WebPushButton from '@magicbell/react/webpush-button';

And then you can use the WebPushButton component in your app:

<WebPushButton
  renderLabel={({ status, error }) => {
    switch (status) {
      case 'loading':
        return <Loading />;
      case 'error':
        return `Error: ${error}`;
      case 'success':
        return 'Unsubscribe';
      default:
        return 'Subscribe';
    }
  }}
  serviceWorkerPath="/sw.js"
/>

The WebPushButton handles notification permissions and subscriptions for you.

5. Send Notifications

You should now be able to open to your app and subscribe to push notifications.
There are a few ways to send notifications - you can use the MagicBell Dashboard to send broadcasts, or you can use the MagicBell CLI.

In the Next.js example app we use the MagicBell Node.js client to create a broadcast. We start by creating a route handler fo sending notifications: src/app/api/send-notification/route.ts, and import the MagicBell client.

import { Client } from 'magicbell-js/project-client';

We create a POST endpoint and get the user's external ID, title, and the content of the notification from the request body. And then use the MagicBell's project client to create a broadcast.

export async function POST(request: Request) {
  loadEnvConfig(process.cwd());

  try {
    const client = new Client({
      token: process.env.MAGICBELL_PROJECT_TOKEN,
    });

    const { externalId, title, content } = await request.json();

    const { data } = await client.broadcasts.createBroadcast({
      title: title,
      content: content,
      recipients: [
        {
          externalId,
        },
      ],
    });

    return Response.json(JSON.stringify(data));
  } catch (reason) {
    // ...
  }
}

While push notifications excel at real-time engagement, they work best as part of a comprehensive messaging strategy. Many successful apps combine push notifications with transactional email best practices to ensure critical information reaches users reliably. For example, an e-commerce PWA might send push notifications for flash sales while using transactional emails for order confirmations and shipping updates—ensuring important transaction details are preserved in the user's inbox.