Web Push under the Hood
Published: March 6, 2025
#web push | #ux | #typescript
In my post “Web Push: Turning Annoyance into Engagement” I motivate and outline essential user experience (UX) guidelines to exploit the full potential of push notifications in the web. This post dives into how web push works and explains the building blocks step by step with code examples for implementing it yourself.
Explore my GitHub repository, where I implemented a simple ToDo-app to demonstrate the functionality of web push notifications with priority on user experience.
Building Blocks
Web push has two main flows: Subscription and Sending. For implementing these flows, we have to deal with client-side logic, the application server, a database, a service worker and a push service. Let’s begin with a brief overview of how these components fit into the flows before we look at them in detail.
The subscription process is triggered on the client-side. In this step, a service worker is registered (allowing the reception of push notifications in the background) and a unique device endpoint (address to that we can send a push message) retrieved from the push service that the subscribing browser uses. The relevant subscription information is sent to the application server and saved in a database.
For displaying a push notification on the device of a subscribed user, the push message is sent from the application server to that device via the push service, based on the subscription information stored in the database. The service worker handles that message in the background and triggers that the actual operating system (OS)-level push notification is shown.
Now let’s explore the components step by step in more detail.
For my example code, i will make use of the Node.js library web-push. A detailed explanation why it is recommended to use a library to send web push notifications can be found in this web.dev article.
Client-side Logic
To be able to send push notifications to a user’s device, the user first needs to subscribe to push notifications. This process involves granting permission to notifications on the browser-level and sending the subscription details to the application server for further processing. Let’s look at some example code covering these steps:
async function subscribeToPush() {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register(
"/service-worker.js"
);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
const response = await fetch("/push-notifications", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
});
// ... error handling
}
At first, we register a service worker (we will look at this component later). Then we use the pushManager
of the service worker registration
to call the subscribe
method. Calling this method triggers the permission request for notifications on browser-level, which is always needed to enable push notifications, regardless of your custom UI components and logic.
As PushSubscriptionOptions
, we set a VAPID (Voluntary Application Server Identification) publicKey
as applicationServerKey
. For signing web push messages (in fact the web push protocol requests), we need a VAPID key pair, containing a publicKey
and a privateKey
. The push requests we send via the application server have to be signed with the privateKey
. The push service, managing the delivery of the push notifications to the devices, uses the publicKey
to authenticate the push requests sent from the application server. Using the web-push
library, you can generate a VAPID key pair with the following command:
npx web-push generate-vapid-keys
The subscription
object is sent to the application server, which we will look at in the next section.
Application Server
The application server handles the logic of saving the subscription information as well as sending the push notifications.
Saving Subscription Information
The server receives the subscription
object and saves it to the database:
type Subscription = {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
};
const subscription: Subscription = await request.json();
await saveToDb(subscription);
The subscription
information contains an endpoint
and two values, that make up the keys
object. The endpoint
is unique for each client and could look like this (Chrome):
The endpoint
domain is dependent on the push service the subscribing browser uses. Google Chrome for example uses Firebase Cloud Messaging (FCM). The uniqueness of the endpoint
value allows you to send push notifications to exactly those people who have subscribed to push notifications without requiring your users to create a custom user profile on your website or web app. The values of the keys
object are used to encrypt the push message sent from the server, so that the push service cannot access its information.
Sending Push Notifications
The following code example shows you how to send push notifications from your server using the web-push
library:
webPush.setVapidDetails(
"mailto:[email protected]",
VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
type PushPayload = {
title: string;
body: string;
url?: string;
};
export async function sendPushNotification(payload: PushPayload) {
const subscriptions: Subscription[] = ... // get relevant subscriptions from db
for (const subscription of subscriptions) {
try {
await webPush.sendNotification(
{
endpoint: subscription.endpoint,
keys: { auth: subscription.keys.auth, p256dh: subscription.keys.p256dh },
},
JSON.stringify(payload),
{
vapidDetails: {
subject: "mailto:[email protected]",
publicKey: VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
},
TTL: 24 * 60 * 60,
}
);
console.info("Push notification sent successfully");
} catch (error) {
console.error("Error sending push notification:", error);
// delete invalid subscription in this case
console.info("Removed invalid subscription:", subscription.endpoint);
}
}
}
At first, you have to set the VAPID details, i.e. the publicKey
and privateKey
. For actually sending the push notification, the sendNotification
method is used. This method uses the endpoint
of each subscription to specify which devices should receive a push notification and the values of the stored keys
object, to encrypt the push message. The Time-To-Live (TTL) specifies, how long a push message should be queued by the push service if it cannot be delivered to the client (might be offline) at the time the message is sent. The payload
contains the content of the push message, in this case a title
, body
(description) and an optional url
for custom navigation on clicking the notification.
Calling the sendNotification
method, the push message is sent from the application server to the push service, that sends it to the respective device via the specific endpoint
. How an arriving push message is handled on a device, we will look at in the next section.
Service Worker
A service worker is a JavaScript file that runs in a separate background thread from the main browser thread, enabling features like offline support and push notifications.
When a push message arrives on a device, the browser that was used to subscribe to the push notifications on a specific website or web app, decrypts the push message and sends a push
event to the registered service worker. Here it is important to point out, that the service worker can handle this push event, regardless of whether the respective website is currently open or not. This ensures that you can bring important information to your users at every time. Let’s look at some example code for the service worker:
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
const { title, body } = data;
const options = {
body,
icon: "/favicon.ico",
data,
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || "/";
event.waitUntil(self.clients.openWindow(urlToOpen));
});
In the above code, we have a push
event listener and a notificationclick
event listener. The push event listener is responsible for receiving the push message and then showing the actual notification on the users device via the showNotification
method. This method offers plenty of options with different browser compatibility. Here we just use the basic ones: The body
that contains a descriptive message in addition to the title
, an icon
that is displayed in the notification for personalization and a data
object that we want to use in the notificationclick
event listener.
The notificationclick
event listener defines the behavior on clicking a displayed notification. In the example code above, the target URL (if present) is extracted from the data object set in the push
event handler to navigate the user to a specific or default URL. The notification.close
call ensures that the notification disappears after clicking it.
The usage of event.waitUntil(...)
ensures, that the service worker is not terminated until the operations showNotification
and openWindow
have been completed.
Since a service worker is mandatory for implementing proper web push notifications, it is worth considering, if you can also make use of other features a service worker offers, like basic offline functionality.
As you can see from the described flow how web push works in general, you do not really have to worry too much about the push service as probably the most exotic component. You just receive the endpoint
when the user subscribes to notifications and the push service takes care of delivering the message to the subscribed devices when sending it from the application server with a library like web-push
.
Advanced Implementations for improved UX
In my post “Web Push: Turning Annoyance into Engagement” I outline some key factors for ensuring good UX for web push. To implement all of them, we need to extend some of the basic implementations for the building blocks described above.
OS-Level Notifications only when in Background
When a user is currently using our web app, we can just show a notification in the web app itself instead of showing an OS-level push notification. To implement that, we can extend the basic service worker implementation from above:
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
const { title, body } = data;
const options = {
body,
icon: "/favicon.ico",
data,
};
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) => {
let clientIsVisible = false;
for (const client of clients) {
if (client.visibilityState === "visible") {
clientIsVisible = true;
client.postMessage({
type: "SHOW_TOAST",
...data,
});
break;
}
}
if (!clientIsVisible) {
self.registration.showNotification(title, options);
}
}),
);
});
What happens in the push
event listener is that we check whether our web site is currently visible on the device, and if so, we call client.postMessage
to send a message to the client, that tells it, to display a toast. If it is not currently visible, we show the OS-level notification.
On the client, we can listen to that message sent from the service worker and add custom logic for displaying the information in the app itself:
type ServiceWorkerMessage = {
type: "SHOW_TOAST";
title: string;
body: string;
url?: string;
};
navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => {
if (!event.data) return;
const data: ServiceWorkerMessage = event.data;
const { type, title, body, url } = data;
if (type === "SHOW_TOAST") {
toast({
title,
description: body,
action: () => navigate(url),
});
}
});
There is also the possibility to show OS-level notifications while a user is actively on your website with the Notification API, without the need for a service worker. But is there really a use case for that? Let’s look at the different scenarios:
As long as the app is visible, you could just use notifications in your app (for example with a toast). If the browser window is minimized (not visible), but not closed, the notifications created with the Notification API would in deed still be shown. But: You would probably also want to see the notifications if the browser is closed. Consequently you could just use the implementation with a service worker suggested above, for little extra cost, but large benefit.
Browser Notification Permission: Build expressive UI
A custom React hook for retrieving the current browser notification permission could look like this:
export function useBrowserPermission() {
const [browserPermission, setBrowserPermission] = useState<
NotificationPermission | "unsupported" | null
>(null);
useEffect(() => {
function updatePermission() {
if ("Notification" in window) {
setBrowserPermission(Notification.permission);
} else {
setBrowserPermission("unsupported");
}
}
updatePermission();
if ("navigator" in window && "permissions" in navigator) {
navigator.permissions.query({ name: "notifications" }).then((status) => {
status.onchange = updatePermission;
});
} else {
window.addEventListener("focus", updatePermission);
return () => window.removeEventListener("focus", updatePermission);
}
}, []);
return browserPermission;
}
Restrictions / Considerations
Push notifications are already a very powerful and promising technology on the web platform. Nevertheless, there are some restrictions and considerations to keep in mind. Let’s look at two specific ones:
-
Notification Actions
Theactions
property for notifications allows you to give your users some options on how to directly interact with notifications. While being a promising feature, unfortunately there is currently only limited browser compatibility. -
nofificationclick
Event
As we saw in the service worker code example above, with anotificationclick
event listener, you can control what exactly should happen when a user clicks a notification and for example navigate to a notification-specific URL. While it seems that this event does not have full baseline browser compatibility (no support for Safari on iOS), there seems to be going something forward here.
Resources
In addition to the links provided in the text above, here are some valuable resources for diving deeper into web push functionality: