Home | Send Feedback

Push notifications with Angular and Java

Published: 6. August 2023  •  angular, java

With the release of iOS 16.4 (March 2023), all major browser platforms support the Push API. With the Push API, a web application can receive messages pushed from a server, and it does not matter if the web app is in the foreground or the background or even loaded in the browser.

In this blog post, I show you how to implement a web application with Angular that receives push notifications and an application server with Java that sends push messages.

How Web Push works

An application server can't send a push message directly to a web app. It has to go through a Push Service responsible for delivering the message to the browser. Each browser vendor runs its own Push Service. These Push Services are free for usage, and you don't need an account.

Each user agent (browser) receives a unique endpoint URL from the Push Service after subscribing to it. An application server uses this endpoint URL to send a push message to the Push Service by HTTP POST requests. Note that topic-based delivery is not possible like in other messaging systems. The application server has to send a message to each client individually. If you have 100,000 subscribers, your application server has to send 100,000 HTTP requests to the Push Services.

The Push API leverages public/private key cryptography to sign and encrypt messages. Two key pairs are involved, one for the application server and one for the browser.

The browser needs to know the application server's public key. It is the unique identifier when the user agent subscribes to the Push Service, and the server signs each outgoing message with the private key. The client verifies with the server public key that the message is indeed from the application server.

The client key pair is used to encrypt the message payload. After subscribing to the Push Service, the client sends his public key to the application server . The application server encrypts the message with the client's public key, and the browser decrypts the message with the client's private key. The decryption process happens transparently in the browser, and the web application receives the message in plain text.

Here is a diagram of the workflow:

Push Architecture

  1. Browser fetches the public key of the application server
  2. Browser subscribes to the Push Service and receives an endpoint URL
  3. Browser sends the client public key and the endpoint URL to the application server
  4. Application server stores the client's public key and the endpoint URL
  5. Application server sends a push message by HTTP POST to each stored endpoint URL. The message is encrypted with the client's public key and signed with the application server's private key.
  6. Push Service delivers the message to the browser
  7. Browser decrypts the message with the client's private key, verifies the signature with the application server's public key, and passes the message to the Service Worker

If you want to learn how to implement encryption and signing from scratch in Java check out my previous blog post.

Setup Angular

The demo application for this blog post consists of just one page with a button to subscribe to push messages and some info texts about the current state.

I created the Angular app with the following command:

# ng new webpushdemo --routing=false --minimal=true --skip-git=true --standalone=true --strict=true --style=css

For an app to receive push messages, it has to have an active Service Worker. Angular makes adding a Service Worker to an existing app very easy. Just run the following command:

# ng add @angular/pwa

This command adds a manifest file and several icons to the project. It also updates the dependencies and configuration files of the app so that it automatically loads and installs a Service Worker when the app starts up.

The Push API is one of the web features that is only available in a secure context. Secure context means that the web app must be delivered over TLS. There is an exception for localhost, which also works with HTTP.

You can test push messages in Angular development mode. To do so, you have to update the file app.config.ts and set the flag enabled to true:

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideServiceWorker('ngsw-worker.js', {
      enabled: true,
      registrationStrategy: 'registerWhenStable:30000'
    })]
};

app.config.ts

Setup Java

The Java back end is a simple Spring Boot application with a REST controller. I created the project with Spring Initializr.

The zerodep-web-push-java library is used for sending push messages. Under the hood, the Push API uses JWT for signing and encryption. zerodep-web-push-java provides several adapter libraries for JWT. It supports the following libraries: auth0, fusionauth, jjwt, jose4j, nimbus-jose and vertx.

This example uses auth0 java-jwt.

    <dependency>
      <groupId>com.zerodeplibs</groupId>
      <artifactId>zerodep-web-push-java</artifactId>
      <version>2.1.1</version>
    </dependency>
    <dependency>
        <groupId>com.zerodeplibs</groupId>
        <artifactId>zerodep-web-push-java-ext-jwt-auth0</artifactId>
        <version>2.1.1</version>
    </dependency>
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>4.4.0</version>
    </dependency>

pom.xml

To create the server key pair, I ran the following commands.

openssl ecparam -genkey -name prime256v1 -noout -out sourceKey.pem
openssl pkcs8 -in sourceKey.pem -topk8 -nocrypt -out vapidPrivateKey.pem
openssl ec -in sourceKey.pem -pubout -conv_form uncompressed -out vapidPublicKey.pem
rm sourceKey.pem

The demo application reads these two keys into memory using the following code.

@Service
public class WebPushService {

  private final VAPIDKeyPair vapidKeyPair;

  public WebPushService() {
    String privateKey = null;
    String publicKey = null;

    try (InputStream privateIs = getClass().getResourceAsStream("/vapidPrivateKey.pem")) {
      privateKey = StreamUtils.copyToString(privateIs, StandardCharsets.UTF_8);
    }
    catch (IOException e) {
      Application.logger.error("can't load vapid private key", e);
    }
    try (InputStream publicIs = getClass().getResourceAsStream("/vapidPublicKey.pem")) {
      publicKey = StreamUtils.copyToString(publicIs, StandardCharsets.UTF_8);
    }
    catch (IOException e) {
      Application.logger.error("can't load vapid public key", e);
    }

    this.vapidKeyPair = VAPIDKeyPairs.of(PrivateKeySources.ofPEMText(privateKey),
        PublicKeySources.ofPEMText(publicKey));
  }

  public String getPublicKey() {
    return this.vapidKeyPair.extractPublicKeyInUncompressedFormAsString();
  }

  public VAPIDKeyPair getKeyPair() {
    return this.vapidKeyPair;
  }

}

WebPushService.java

iOS quirks

Before we take a closer look at the code, there are some things to consider when using web push messages on iOS.

The Push API has been supported on iOS since version 16.4.

Unlike the other platforms, a web application on iOS can only subscribe to a Push Service and receive push messages if it's installed on the home screen. Apple calls these apps "Home Screen web apps". Push API does not work when the user opens the web app in Safari.

Unfortunately, Apple makes it not so convenient to install a web app on the home screen, like it is on Android with Chrome. The user has to open the web app in Safari, then open the menu and select "Add to Home Screen". Chrome on Android automatically shows a dialog to install the web app on the home screen when the user visits the web app for the first time.

Like other platforms, web applications must ask the user for permission to receive push messages. On iOS, requesting permission must be done in response to a user gesture (e.g., a click on a button). On Android, I could request permission on page load in a ngOnInit method. This is not possible on iOS.

Also, like the other platforms, iOS does not support invisible push messages. The web app must display a notification dialog. The Angular Service Worker takes care of this. It always displays a notification dialog when a push message is received.

Another difference I noted is that iOS notification dialogs do not support custom actions. You can send push messages with custom actions on Android and Chrome on Windows. The notification dialog on these platforms then shows a button for each action in the dialog. This does not work on iOS. Also, iOS does not show the custom icon specified in the push message. Instead, it shows a default icon.

Check out the Apple developer documentation for more information about web push notifications on iOS.

Implementation details

The Angular app uses the following HTML template. It shows a subscribe button if the user has not subscribed yet. It shows an unsubscribe button if the user has subscribed. If the user has denied permission to receive push messages, it shows a message, and if the Push API is not implemented, it shows the text Web Push not supported.

<div *ngIf="webPushSupported" class="content">
  <button (click)="subscribe()" *ngIf="!subscribed && !permissionDenied">Subscribe</button>
  <div *ngIf="permissionDenied">Permission denied</div>
  <button (click)="unsubscribe()" *ngIf="subscribed && !permissionDenied" class="unsubscribe">Unsubscribe</button>
</div>

<div *ngIf="!webPushSupported" class="content">
  <div class="info">Web Push not supported</div>
</div>

app.component.html

In Angular, we can inject the SwPush service, which the application uses for subscribing and unsubscribing. The SwPush service is part of the Angular Service Worker.

In the ngOnInit method, the application checks if the browser supports web push messages. The SwPush service provides the isEnabled property for this. If true the browser supports the Push API.

Unfortunately, the isEnabled property is also true on iOS 16.4+ when the user opens the web app in Safari. Therefore, the code employs an additional check for iOS.
window.navigator.standalone is a property that is only available on iOS. It is true when the user has installed the web app on the home screen and opened it from there.

The code then checks whether the user has subscribed to the Push Service. SwPush.subscription is an observable that emits the current subscription. If the user has subscribed, the method sets the subscribed property to true and sends the subscription to the server. The subscription object contains the endpoint URL and the client's public key.

  async ngOnInit() {
    // standalone: boolean indicating whether the browser is running in standalone mode.
    // Available on Apple's iOS Safari only
    const isIOS = 'standalone' in window.navigator;
    const isIOSStandalone = 'standalone' in window.navigator && window.navigator.standalone === true;
    this.enabled = this.#swPush.isEnabled;
    if (this.#swPush.isEnabled && (!isIOS || isIOSStandalone)) {
      this.webPushSupported = true;

      // fetch the current subscription
      this.#currentSubscription = await firstValueFrom(this.#swPush.subscription)
      if (this.#currentSubscription) {
        this.subscribed = true;
        await lastValueFrom(this.#httpClient.post(`${environment.SERVER_URL}/subscribe`, this.#currentSubscription));
      }
    } else {
      this.webPushSupported = false;
    }
  }

app.component.ts


The subscribe method is called when the user clicks the Subscribe button. Here the application first fetches the server's public key with a GET request. Then it requests a subscription from the Push Service with SwPush.requestSubscription. This triggers the browser to show a dialog to the user to ask for permission to receive push messages. SwPush.requestSubscription returns a Promise that is rejected if the user denies permission.

Note that a user only sees this dialog once. To reset the permission, the user has to go to the browser settings.

When the user grants permission, the application sends a request to the Push Service and receives the endpoint URL. The Push API wraps the endpoint URL in a PushSubscription object together with the public key of the client and returns it to the application. Lastly, the application sends this subscription object to our application server.

The client is now ready to receive push messages.

  async subscribe() {
    if (!this.#currentSubscription) {
      this.#serverPublicKey = await lastValueFrom(this.#httpClient.get(`${environment.SERVER_URL}/publicKey`, {responseType: 'text'}))

      try {
        this.#currentSubscription = await this.#swPush.requestSubscription({
          serverPublicKey: this.#serverPublicKey!
        });
      } catch (e) {
        console.error(e);
        this.permissionDenied = true;
        return;
      }
    }

    if (this.#currentSubscription) {
      await lastValueFrom(this.#httpClient.post(`${environment.SERVER_URL}/subscribe`, this.#currentSubscription));
      this.subscribed = true;
    } else {
      this.subscribed = false;
    }
  }

app.component.ts

An alternative way to check if the user has granted the web app to receive push messages is to use the Permission API

const permission = await navigator.permissions.query({name: 'notifications'});
    switch (permission.state) {
    case 'granted':
        // user granted permission
        break;
    case 'denied':
        // user denied permission
        break;
    case 'prompt':
        // user did not grant or deny permission yet
        break;
}

Here is the implementation of the /publicKey endpoint in the Spring Boot application.

  @GetMapping(path = "/publicKey")
  public String publicKey() {
    return this.webPushService.getPublicKey();
  }

WebPushController.java

Note that the Angular Service Worker expects the server public key as a string in base64 format. The zerodep-web-push-java library has a convenient method that returns the public key in this format.

  public String getPublicKey() {
    return this.vapidKeyPair.extractPublicKeyInUncompressedFormAsString();
  }

WebPushService.java

The subscriptions are tight to the public key of the server. Therefore, you can't change the application server key pair if there are active subscriptions. Unless you find a way to force all clients to re-subscribe.

The /subscribe endpoint receives the subscription object from the client with the endpoint URL and the client's public key and stores it in a map. You would store the subscription in persistent storage for a real application. The endpoint URL is unique for each client and can be used as a primary key.

  @PostMapping("/subscribe")
  @ResponseStatus(HttpStatus.CREATED)
  public void subscribe(@RequestBody PushSubscription subscription) {
    Application.logger.info("subscribe: " + subscription);
    this.pushSubscriptions.put(subscription.getEndpoint(), subscription);
  }

WebPushController.java


Sending push messages

The demo application uses the following scheduled method to send push messages to all subscribers via the Push Service every minute. It reads a random joke from the Chuck Norris Joke API, creates a JSON message with the joke, loops over all subscriptions and sends an HTTP POST request to the endpoint URL.

The zerodep-web-push-java library supports different HTTP client libraries (Apache, OkHttp, Vertx, Jetty) and the Java 11 HTTP client library, which I use in this example.

  @Scheduled(fixedDelayString = "PT1M")
  public void sendJokes() {
    if (this.pushSubscriptions.isEmpty()) {
      return;
    }

    Joke joke = this.chuckNorrisJokeService.getRandomJoke();

    Application.logger.info("sending joke to subscribers: {}", joke.id());

    String msg = """
        {
          "notification": {
             "title": "{title}",
             "body": "{body}",
             "icon": "assets/icons/icon-72x72.png",
             "data": {
               "onActionClick": {
                 "default": {"operation": "navigateLastFocusedOrOpen", "url": "/"},               }
             }
          }
        }
        """
        .replace("{title}", "Chuck Norris Joke").replace("{body}", joke.value());

    for (PushSubscription subscription : this.pushSubscriptions.values()) {
      HttpRequest request = StandardHttpClientRequestPreparer.getBuilder()
          .pushSubscription(subscription).vapidJWTExpiresAfter(3, TimeUnit.HOURS)
          .vapidJWTSubject("mailto:example@example.com").pushMessage(msg)
          .ttl(1, TimeUnit.HOURS).urgencyNormal().topic("Joke")
          .build(this.webPushService.getKeyPair()).toRequest();

      try {
        HttpResponse<String> httpResponse = this.httpClient.send(request,
            HttpResponse.BodyHandlers.ofString());

        switch (httpResponse.statusCode()) {
        case 201 -> {
          Application.logger.info("Push message successfully sent: {}",
              httpResponse.body());
        }
        case 404, 410 -> {
          Application.logger.warn("Subscription not found or gone: {}",
              subscription.getEndpoint());
          // remove subscription
          this.pushSubscriptions.remove(subscription.getEndpoint());
        }
        case 429 -> {
          Application.logger.error("Too many requests: {}", request);
          // TODO: retry
        }
        case 400 -> {
          Application.logger.error("Invalid request: {}", request);
          // TODO: something is wrong with the request
        }
        case 413 -> {
          Application.logger.error("Payload size too large: {}", request);
          // TODO: decrease payload
        }
        default -> {
          Application.logger.error("Unhandled status code: {} / {}",
              httpResponse.statusCode(), request);
          // TODO: might be a temporary problem with the push service. retry
        }
        }

      }
      catch (IOException | InterruptedException e) {
        Application.logger.error("sending to push notification failed", e);
      }

    }

  }

WebPushController.java

The payload of the push message is a JSON object with a notification property. It supports the fields described in the documentation. Note that not all properties are supported by all browsers. For example, icon was ignored by iOS when I tested it.

Only title is required.

topic is a string that can be used to replace pending messages with a new message if they have matching topic names. This is useful in scenarios where multiple messages are sent while a device is offline, and you only want a user to see the latest message when the device is turned on.

The data property is a JSON object that can be used to pass custom actions. The default action tells the Angular Service Worker what to do when the user clicks or taps on the notification dialog. In this example, it navigates to the application root page. You can also specify an absolute URL to navigate to a different website.

You find a description of the different operations on this page. Besides the default action, you can add custom actions. Each action can have a different operation and URL and, on supported platforms, are presented as buttons in the notification dialog. As mentioned before, this is not supported on iOS.

vapidJWTSubject must be either a URL or a mailto email address. This should be a valid URL or email address that the Push Service can use to contact the application owner if there is a problem.

The application must check the response status code of the HTTP request. A Push Service can return the following status codes:

Status Code Description
201 Created. The request to send a push message was received and accepted.
429 Too many requests. Meaning your application server has reached a rate limit with a push service. The push service should include a Retry-After header to indicate how long before another request can be made.
400 Invalid request. This generally means one of your headers is invalid or improperly formatted.
404 Not Found. This indicates that the subscription is expired and can't be used. In this case, you should delete the subscription.
410 Gone. The subscription is no longer valid and should be removed from the application server.
413 Payload size too large. According to the Web Push RFC, a Push Service must support messages with a payload size of up to 4 KB.

Note that returning the code 201 does not mean that the push message was delivered to the client; it was only accepted by the Push Service. The Push Service will try to deliver the message to the client, but it might fail if the client is offline or the device is turned off. When the TTL of the message expires during this time, the message will be discarded and not delivered to the client.


Unsubscribe

To unsubscribe a client from push notifications, it may call the unsubscribe method on the PushSubscription object. In this example, it also sends a request to the application server, which is not strictly necessary, because when the application server tries to send a push message for this client, it receives back a 404 or 410 status code from the Push Service and then removes the subscription from the database.

  async unsubscribe() {
    if (this.#currentSubscription) {
      await this.#currentSubscription.unsubscribe();
      await lastValueFrom(this.#httpClient.post(`${environment.SERVER_URL}/unsubscribe`, this.#currentSubscription));
      this.#currentSubscription = null;
      this.subscribed = false;
    }
  }

app.component.ts

The application server removes the subscription from the map of subscriptions.

  @PostMapping("/unsubscribe")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  public void unsubscribe(@RequestBody PushSubscription subscription) {
    Application.logger.info("unsubscribe: " + subscription);
    this.pushSubscriptions.remove(subscription.getEndpoint());
  }

WebPushController.java


This concludes the blog post about receiving and sending web push notifications with Angular and Java. I hope you found it helpful. For more information, check out the following links: