Push Notifications
Push notifications can reach users even when an app isn't open. The browser subscribes once, the app stores the subscription, and the server uses it to deliver notifications when an event occurs. Push manages subscribing and unsubscribing in the browser. On the server, PushSender sends a PushMessage to a stored subscription.
Setup and prerequisites
Push notifications are provided by a separate module. Add it to your app:
- Maven
- Gradle
<dependency>
<groupId>com.webforj</groupId>
<artifactId>webforj-push</artifactId>
</dependency>
dependencies {
implementation 'com.webforj:webforj-push'
}
Push notifications require:
- A servlet deployment, such as Jetty, Spring Boot, or a WAR file.
- A key pair, generated below, that the deployment uses to sign notifications.
- A secure origin. Browsers reject subscriptions served over anything but
https, except fromlocalhostduring development.
For more information about secure contexts and why they matter, see the Secure Contexts MDN documentation.
Generating the keys
Push services accept only notifications signed by the deployment to which the browser subscribed. Run the build plugin once for each deployment to generate its key pair:
- Maven
- Gradle
mvn webforj:push-keys
./gradlew webforjPushKeys
The command outputs three configuration lines. Paste them into application.properties without the quotes, or copy them as printed into webforj.conf. Replace the subject with the deployment's contact address. It must be a mailto: or https:// address that push services can use to contact the operator.
webforj.push.public-key=...
webforj.push.private-key=...
webforj.push.subject=mailto:ops@example.com
| Property | Explanation |
|---|---|
webforj.push.public-key | The public half of the key pair used by the deployment to sign notifications |
webforj.push.private-key | The private half of the key pair. Like any other secret, keep it out of source control |
webforj.push.subject | The deployment's contact address. It must be a mailto: or https:// address through which push services can reach the operator |
The app reads these properties at startup. If the configuration includes only some of them, startup fails and reports which properties are missing.
Each browser subscribes to one key pair. If the keys change, the push service rejects existing subscriptions. The next subscribe() call in each browser replaces its subscription.
How it works
The process has three steps:
- Subscribe. From a view,
Push.getCurrent().subscribe()requests the user's permission and returns aPushSubscriptionthat identifies the browser's address. - Store. The app saves the subscription with its data and associates it with the corresponding user.
- Send. Later, from any thread,
PushSender.send(subscription, message)passes the message to the browser vendor's push service. The service displays the notification whether or not the app is open.
Push.getCurrent().subscribe().thenAccept(subscriptions::save);
sender.send(subscription,
PushMessage.create("Order shipped").setUrl("/orders/42").build());
The following sections explain what the browser displays and how to handle failures at each step.
Instance
Retrieve the push instance for the current environment:
import com.webforj.push.Push;
Push push = Push.getCurrent();
if (Push.isPresent()) {
// ...
}
Push.ifPresent(p -> {
// ...
});
Subscribing the browser
Call subscribe() in response to a user action, such as clicking an "Enable notifications" button. The returned PendingResult completes with the browser's PushSubscription. If the browser can't subscribe, it completes exceptionally with a WebforjPushException.
PendingResult<PushSubscription> request = Push.getCurrent().subscribe();
request.thenAccept(subscription -> {
subscriptions.save(subscription);
});
request.exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable.getCause();
PushStatus status = error.getStatus();
String message = error.getMessage();
return null;
});
If the browser is already subscribed, calling subscribe() again returns the existing subscription. You can therefore call it safely on every visit.
The first call to subscribe() prompts the user for permission. The browser displays this prompt, it isn't part of the app UI. Because browsers show the prompt only in response to a user action, call subscribe() from a click listener instead of the view constructor.
If the user blocks the prompt, the app can't prompt again for that origin.
Storing subscriptions
A subscription represents the address of one browser and belongs on the server. Store it with the app's data, using its endpoint as the key. Include any information the app needs to select the appropriate browsers later, such as the associated user. Each subscription contains three text values:
| Value | Meaning |
|---|---|
getEndpoint() | The delivery URL assigned by the browser vendor's push service |
getP256dh() | The browser's public key |
getAuth() | The browser's authentication secret |
A user who subscribes from two browsers has two subscriptions. Delete a subscription when its browser unsubscribes or when a send reports that it has expired. See Failure status.
Restoring a subscription
getSubscription() returns the browser's current subscription, or an empty result if none exists. Use it to synchronize the server's copy, for example after the app's storage has been reset:
Push.getCurrent().getSubscription().thenAccept(existing -> {
existing.ifPresent(subscriptions::save);
});
Through PushPermission, getPermission() reports whether the user granted, denied, or hasn't yet answered the notification prompt. Use this result to hide the "Enable notifications" button when clicking it would have no effect.
Unsubscribing
unsubscribe() cancels the browser's subscription. It completes with the removed subscription so the app can delete its stored copy, or with an empty result if the browser had no subscription.
Push.getCurrent().unsubscribe().thenAccept(removed -> {
removed.ifPresent(subscriptions::delete);
});
Sending notifications
PushSender sends a PushMessage to a stored subscription. It signs the message with the deployment's keys and passes it to the browser vendor's push service. That service wakes the browser and displays the notification. Because the operation never blocks the calling thread, you can invoke it from a click listener, scheduled job, or request handler.
After the properties are configured, the sender is available as a bean that you can inject into views, services, and scheduled jobs. To replace it, define your own PushSender bean.
@Route("/orders")
public class OrdersView extends Composite<FlexLayout> {
public OrdersView(PushSender sender, PushSubscriptions subscriptions) {
// ...
}
}
Without Spring, new PushSender() reads the keys from the app's configuration. Create the sender on an app thread, either in a view or in App.run(), and then use it from any thread. All senders share one connection pool to the push services, so there is no cost to creating one wherever needed.
For notifications that must be sent later or after the user leaves, use a timer on the server such as Spring's TaskScheduler. Don't use a page timer such as Interval, because it stops when the tab closes.
Composing a message
Create a message with its title, then configure every other option on the builder:
PushMessage message = PushMessage.create("Order shipped")
.setBody("Order #42 is on its way")
.setIcon("icons://icon-192x192.png")
.setUrl("/orders/42")
.setActions(List.of(new PushAction("track", "Track", "/orders/42/tracking")))
.build();
PendingResult<Void> sent = sender.send(subscription, message);
sent.thenAccept(v -> status.setText("Sent"));
sent.exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable;
status.setText(error.getStatus() + ": " + error.getMessage());
return null;
});
send() returns immediately. The PendingResult completes when the push service accepts the message, or completes exceptionally if the service doesn't accept it. If send() is called on an app thread, such as from a listener, its callbacks run on that thread and can update components. If the session that called send() ends before the response arrives, the callbacks don't run, but the notification is still delivered.
A send waits up to 30 seconds for the push service before failing with UNREACHABLE. Use setTimeout(Duration) to change the timeout for each sender.
| Option | Effect |
|---|---|
setBody | Sets the text displayed below the title |
setIcon | Sets the image displayed with the notification. It accepts absolute URLs and the icons:// and ws:// protocols. See Assets. It doesn't accept the context:// protocol because push services limit a message to 4 KB |
setUrl | Sets the page that opens when the user clicks the notification. Relative URLs are resolved against the app root. If no URL is set, the app root opens |
setActions | Sets the buttons displayed on the notification, with a separate URL for each button. See Browser support |
setTag | Sets an identifying tag. If a displayed notification has the same tag, the new notification replaces it |
setSilent | Displays the notification without sound or vibration |
setTimeToLive | Sets how long the push service retains the message for an offline device, up to four weeks |
setUrgency | Uses PushUrgency to let the device delay messages of low urgency and save battery |
setTopic | Replaces a message that's still waiting at the push service when both messages have the same topic. Topics can contain at most 32 characters that are safe in a URL |
When a tab already displays the page, clicking the notification focuses the app. Otherwise, the page opens in a new tab. Clicking a notification button opens its URL in the same way.
Every message displays a notification. Because browsers don't wake a page for a message that displays nothing, push can't be used for silent data updates.
Failure status
When subscribe() or send() fails, its PendingResult reports a WebforjPushException. PushStatus identifies the reason:
| Status | When | What to do |
|---|---|---|
PERMISSION_DENIED | The user has blocked notifications for the app | Explain where the user can allow notifications in the browser settings |
UNSUPPORTED | Push isn't supported by the browser, the page isn't in a secure context, or the app isn't deployed as a servlet | Hide the feature |
NOT_CONFIGURED | At least one webforj.push.* property is missing or incomplete | Generate the keys and configure all three properties |
SUBSCRIPTION_EXPIRED | The push service no longer recognizes the subscription because the user unsubscribed or reinstalled the browser | Remove the stored subscription |
REJECTED | The push service rejected the message; getStatusCode() contains its response | Verify the keys and message size |
UNREACHABLE | The push service didn't respond before the timeout | Try again later |
UNKNOWN | The stored endpoint isn't a valid URL, or the subscription or message couldn't be encoded | Verify the stored subscription |
Remove expired subscriptions during each send:
sender.send(subscription, message).exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable;
if (error.getStatus() == PushStatus.SUBSCRIPTION_EXPIRED) {
subscriptions.delete(subscription);
}
return null;
});
Push services deregister subscriptions lazily. They still accept the first message after a user unsubscribes, but it goes nowhere. The next message reports SUBSCRIPTION_EXPIRED. An accepted send means that the message reached the push service, not that the user saw it.
Browser support
All major desktop and mobile browsers display push notifications after subscribing. Keep these limitations in mind:
- On iPhone and iPad, push works only for web apps added to the Home Screen on iOS 16.4 or later. In a Safari tab,
subscribe()reportsUNSUPPORTED. See Installable Apps for the required app manifest. - Safari doesn't display notification buttons. It displays messages with actions without their buttons, but clicking the notification still opens the message URL.
- Android and iOS WebViews don't display notifications.
For the details per browser, see the MDN showNotification compatibility table.
Complete example
The following view subscribes and unsubscribes the browser, stores subscriptions in memory, and sends a message to every stored subscription. It can send immediately or wait eight seconds by using Spring's TaskScheduler, allowing the tab to close before the notification arrives. The app class uses @EnableScheduling to make the scheduler available.
package com.example;
import com.webforj.push.PushSubscription;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
@Service
public class PushSubscriptions {
private final Map<String, PushSubscription> byEndpoint = new ConcurrentHashMap<>();
public void save(PushSubscription subscription) {
byEndpoint.put(subscription.getEndpoint(), subscription);
}
public void delete(PushSubscription subscription) {
byEndpoint.remove(subscription.getEndpoint());
}
public Collection<PushSubscription> findAll() {
return byEndpoint.values();
}
}