June 2, 2022Marcus Hellberg

Sending web push notifications from Spring Boot

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.

A notification message above an open browser window.
A notification message above an open browser window.

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

A diagram showing a server, browser, and endpoint. The server has a public and private key. The browser uses the public key to subscribe to the endpoint.
A diagram showing a server, browser, and endpoint. The server has a public and private key. The browser uses the public key to subscribe to the endpoint.

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

A diagram showing a server signing a message with the private key, sending it to the endpoint, which decrypts the header and sends the message to the device
A diagram showing a server signing a message with the private key, sending it to the endpoint, which decrypts the header and sends the message to the device

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:

  1. 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.
  2. 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 🤷‍♂️)
  3. 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 event data property as JSON to access the message payload.
    • Use self.registration.showNotification() to show a notification using the message data.
  • 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.

Marcus Hellberg

Marcus Hellberg

Marcus is the VP of Developer Relations at Vaadin. His daily work includes everything from writing blogs and tech demos to attending events and giving presentations on all things Vaadin and web-related.

© 2022 Vaadin. All rights reserved