Web push notifications are a way of informing your app users when something important has happened.
Users can receive web push notifications even when they are not actively using your application, for instance, if the app is open in a background tab or even if it's not open.
Push notifications are widely supported by all browsers except for Safari: 78% of web users use a browser that supports them.
In this tutorial, I'll show you how to subscribe to notifications in the browser and how to send notifications from a Java server.
Video version
A bit of background: how web push notifications work
Web push notifications rely on two web standards: Notification API and Push API (which in turn uses ServiceWorker). They require HTTPS to work.
Subscribing to push notifications
- The server shares its public key with the browser
- The browser uses the public key to subscribe to a push service (each browser have their own)
- The push service returns a subscription with a unique endpoint URL that can be used to send push messages
- The subscription is saved to the server
Image courtesy of Google Web Fundamentals
Sending push notifications
- The server signs an authorization header with its private key
- The server sends the message to the unique endpoint URL
- The push server decrypts the auth header
- The push server sends the message to the device/browser
Image courtesy of Google Web Fundamentals
Setup project and generate VAPID keys
I'm using Hilla for this example. Hilla uses Spring Boot on the backend and Lit on the frontend.
I will only cover the key steps here. You can find the complete source code on GitHub.
You can create a new Fusion project with the Vaadin CLI:
npx @vaadin/cli init --hilla push-app
Create a set of VAPID keys with the web-push
npm package.
npx web-push generate-vapid-keys
Create a new file .env
in the project directory and use it to store the keys. Add it to your .gitignore
so you don't accidentally publish it.
export VAPID_PUBLIC_KEY=BAwZxXp0K....
export VAPID_PRIVATE_KEY=1HLNMKEE....
Add the Java WebPush and BouncyCastle library dependency to pom.xml
:
<dependency>
<groupId>nl.martijndwars</groupId>
<artifactId>web-push</artifactId>
<version>5.1.1</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
Load the environment file and start the app:
source .env
mvn
Create a Java service for handling subscriptions and sending notifications
Create a new Spring Boot service, MessageService.java
. This service will read in the keys and
package com.example.application;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.Security;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.jose4j.lang.JoseException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
import nl.martijndwars.webpush.Subscription;
@Service
public class MessageService {
@Value("${vapid.public.key}")
private String publicKey;
@Value("${vapid.private.key}")
private String privateKey;
private PushService pushService;
private List<Subscription> subscriptions = new ArrayList<>();
@PostConstruct
private void init() throws GeneralSecurityException {
Security.addProvider(new BouncyCastleProvider());
pushService = new PushService(publicKey, privateKey);
}
public String getPublicKey() {
return publicKey;
}
public void subscribe(Subscription subscription) {
System.out.println("Subscribed to " + subscription.endpoint);
this.subscriptions.add(subscription);
}
public void unsubscribe(String endpoint) {
System.out.println("Unsubscribed from " + endpoint);
subscriptions = subscriptions.stream().filter(s -> !endpoint.equals(s.endpoint))
.collect(Collectors.toList());
}
public void sendNotification(Subscription subscription, String messageJson) {
try {
pushService.send(new Notification(subscription, messageJson));
} catch (GeneralSecurityException | IOException | JoseException | ExecutionException
| InterruptedException e) {
e.printStackTrace();
}
}
@Scheduled(fixedRate = 15000)
private void sendNotifications() {
System.out.println("Sending notifications to all subscribers");
var json = """ { "title": "Server says hello!", "body": "It is now: %s" } """;
subscriptions.forEach(subscription -> {
sendNotification(subscription, String.format(json, LocalTime.now()));
});
}
}
Some key things to note:
- The
@Value("${vapid.public.key}")
annotation reads the environment variables into the fields. - The service stores the subscriptions in a
List
. In a more practical application, you would keep them in a database along with the user. - You send push notifications with
pushService.send(new Notification(subscription, messageJson))
. The payload can also be plain text, but JSON is more flexible. - The service sends out a notification to all subscribers every 15 seconds, containing the current time.
Create an Endpoint for accessing the server
Next, you need a way to access the server from the browser. In Vaadin Fusion, you do this by defining an Endpoint. The endpoint will generate TypeScript types and TS accessor methods you can use in the client code.
package com.example.application;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import dev.hilla.Endpoint;
import dev.hilla.Nonnull;
import nl.martijndwars.webpush.Subscription;
@Endpoint
@AnonymousAllowed
public class MessageEndpoint {
private MessageService messageService;
public MessageEndpoint(MessageService messageService) {
this.messageService = messageService;
}
public @Nonnull String getPublicKey() {
return messageService.getPublicKey();
}
public void subscribe(Subscription subscription) {
messageService.subscribe(subscription);
}
public void unsubscribe(String endpoint) {
messageService.unsubscribe(endpoint);
}
}
Some things to note:
- Endpoints are secured by default. You can allow anonymous access with
@AnonymousAllowed
. - The endpoint injects the message service and delegates subscribing and unsubscribing to it.
Subscribe to notifications in the browser
Create a view for subscribing to notifications. The LitElement component keeps track of two pieces of state:
- whether the user has allowed notifications
- whether the user has an existing push subscription
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "@vaadin/button";
import { View } from "../view";
import { MessageEndpoint } from "Frontend/generated/endpoints";
@customElement("notifications-view")
export class NotificationsView extends View {
@state() denied = Notification.permission === "denied";
@state() subscribed = false;
render() {
return html` <h1>Web Push Notifications 📣</h1> ${this.denied ? html` <b> You have blocked notifications. You need to manually enable them in your browser. </b> ` : ""} ${this.subscribed ? html` <p>Hooray! You are subscribed to receive notifications 🙌</p> <vaadin-button theme="error" @click=${this.unsubscribe} >Unsubscribe</vaadin-button > ` : html` <p>You are not yet subscribed to receive notifications.</p> <vaadin-button theme="primary" @click=${this.subscribe} >Subscribe</vaadin-button > `} `;
}
async firstUpdated() {
const registration = await navigator.serviceWorker.getRegistration();
this.subscribed = !!(await registration?.pushManager.getSubscription());
}
async subscribe() {
const notificationPermission = await Notification.requestPermission();
if (notificationPermission === "granted") {
const publicKey = await MessageEndpoint.getPublicKey();
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlB64ToUint8Array(publicKey),
});
if (subscription) {
this.subscribed = true;
// Serialize keys uint8array -> base64
MessageEndpoint.subscribe(JSON.parse(JSON.stringify(subscription)));
}
} else {
this.denied = true;
}
}
async unsubscribe() {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await MessageEndpoint.unsubscribe(subscription.endpoint);
this.subscribed = false;
}
}
private urlB64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
}
The important part here is the subscribe()
-method. Here is what it does:
- Asks the user for permission to show notifications with
Notification.requestPermission()
. The answer will be "granted" or "denied". NOTE: If the user declines, you cannot ask them again. Be sure to only prompt the user when they expect and want notifications. - If the user grants permission, fetch the public key from the server and use the ServiceWorker PushManager to subscribe to notifications. The
applicationServerKey
is a Uint8Array containing the public key. You need to convert it with the included method. (Not the most convenient API 🤷♂️) - If the subscription succeeds, send it to the server.
Handle incoming push messages in the ServiceWorker
Once you are subscribed to notifications, the server will send out a notification every 15 seconds.
Override the Vaadin generated ServiceWorker by copying target/sw.ts
-> frontend/sw.ts
.
Add the following two listeners to sw.ts
:
self.addEventListener("push", (e) => {
const data = e.data?.json();
if (data) {
self.registration.showNotification(data.title, {
body: data.body,
});
}
});
self.addEventListener("notificationclick", (e) => {
e.notification.close();
e.waitUntil(focusOrOpenWindow());
});
async function focusOrOpenWindow() {
const url = new URL("/", self.location.origin).href;
const allWindows = await self.clients.matchAll({
type: "window",
});
const appWindow = allWindows.find((w) => w.url === url);
if (appWindow) {
return appWindow.focus();
} else {
return self.clients.openWindow(url);
}
}
- The
fetch
listener gets called when a new message comes in. Read the eventdata
property as JSON to access the message payload.- Use
self.registration.showNotification()
to show a notification using the message data.
- Use
- The
notificationclick
listener gets called when you click on the notification.- Close the notification.
- See if the user has an open tab application tab. If they do, focus it. If they don't, open a new window.
Source code
You can find the complete source code on my GitHub: https://github.com/marcushellberg/hilla-push-notifications.