Push Notifications
Push notifications let your app re-engage users even when no tab is open, delivering timely messages through the browser’s Push API. Angular wraps the low-level plumbing in SwPush, a service exposed by @angular/service-worker that handles subscription, permission prompts, and incoming-message streams. This page shows how to request a push subscription, send it to your backend, and react to notifications and their clicks — all from a modern standalone Angular app.
How web push works
Web push relies on three parties. The browser’s push service (FCM for Chrome, Mozilla’s autopush for Firefox, etc.) is the relay endpoint. Your application server holds a VAPID key pair and signs every outgoing message. The service worker receives the encrypted payload in the background and displays a notification. Angular’s SwPush sits on top of this, giving you a subscription object to register and observables to consume.
A subscription only works over HTTPS (or localhost) and requires a service worker — so this builds directly on having run ng add @angular/pwa.
Generating VAPID keys
VAPID (Voluntary Application Server Identification) keys authenticate your server to the push service. Generate a pair once with the web-push CLI and keep the private key secret on the server.
npx web-push generate-vapid-keys
Output:
=======================================
Public Key:
BJ8b...g9Tq (base64url, 87 chars)
Private Key:
hZc2...kP4 (keep this secret)
=======================================
The public key is safe to embed in the client; the private key lives only on your backend.
Requesting a subscription
SwPush.requestSubscription() triggers the browser’s permission prompt and, once granted, returns a PushSubscription. Inject the service and gate the call behind isEnabled, which is false whenever the service worker isn’t active (for example during ng serve).
import { Component, inject, signal } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { HttpClient } from '@angular/common/http';
const VAPID_PUBLIC_KEY = 'BJ8b...g9Tq';
@Component({
selector: 'app-notify-button',
standalone: true,
template: `
@if (push.isEnabled) {
<button (click)="subscribe()" [disabled]="busy()">
Enable notifications
</button>
@if (error()) {
<p class="error">{{ error() }}</p>
}
} @else {
<p>Push is not available in this environment.</p>
}
`,
})
export class NotifyButtonComponent {
readonly push = inject(SwPush);
private readonly http = inject(HttpClient);
readonly busy = signal(false);
readonly error = signal<string | null>(null);
async subscribe(): Promise<void> {
this.busy.set(true);
this.error.set(null);
try {
const sub = await this.push.requestSubscription({
serverPublicKey: VAPID_PUBLIC_KEY,
});
await this.http.post('/api/subscriptions', sub).toPromise();
} catch (err) {
this.error.set('Could not enable notifications.');
console.error('Subscription failed', err);
} finally {
this.busy.set(false);
}
}
}
requestSubscription rejects if the user denies permission or the browser blocks the prompt, so always wrap it in a try/catch.
Browsers will permanently silence prompts if you call
requestSubscriptionon page load without a user gesture. Always trigger it from a click or another explicit interaction.
Persisting the subscription
The PushSubscription you POST to the backend is a JSON object containing the endpoint URL and the encryption keys the server needs to send messages.
{
"endpoint": "https://fcm.googleapis.com/fcm/send/abc123...",
"expirationTime": null,
"keys": {
"p256dh": "BPx...",
"auth": "k9R..."
}
}
Store one row per subscription. A single user with multiple devices produces multiple subscriptions, and endpoints can expire — so treat them as disposable and prune the ones the push service rejects with a 410 Gone.
Reacting to messages and clicks
SwPush exposes two observables. messages emits the raw data payload of every push, useful for updating in-app state. notificationClicks emits when the user clicks a notification, letting you route them somewhere meaningful.
import { Component, inject } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-push-listener',
standalone: true,
template: ``,
})
export class PushListenerComponent {
private readonly push = inject(SwPush);
private readonly router = inject(Router);
constructor() {
this.push.messages
.pipe(takeUntilDestroyed())
.subscribe((msg) => console.log('Push payload', msg));
this.push.notificationClicks
.pipe(takeUntilDestroyed())
.subscribe(({ action, notification }) => {
const url = notification.data?.url ?? '/';
this.router.navigateByUrl(url);
});
}
}
Sending a notification from the server
On the backend, use the web-push library to deliver a payload. The structure you send maps to the Notification API options the service worker displays.
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
const payload = JSON.stringify({
notification: {
title: 'New comment',
body: 'Someone replied to your post.',
icon: '/icons/icon-192x192.png',
data: { url: '/posts/42' },
actions: [{ action: 'open', title: 'View' }],
},
});
await webpush.sendNotification(subscription, payload);
Angular’s default service worker understands the notification envelope above and renders it automatically — no custom worker code required.
SwPush API reference
| Member | Type | Purpose |
|---|---|---|
isEnabled | boolean | Whether the service worker is active and push is usable. |
requestSubscription(opts) | Promise<PushSubscription> | Prompts for permission and subscribes. |
unsubscribe() | Promise<void> | Cancels the current subscription. |
subscription | Observable<PushSubscription | null> | Current subscription, emits on change. |
messages | Observable<object> | Payload data of each incoming push. |
notificationClicks | Observable<{ action, notification }> | Fires when a notification is clicked. |
Best Practices
- Always check
isEnabledbefore calling subscription APIs so non-PWA environments degrade gracefully. - Trigger
requestSubscriptiononly from a genuine user gesture to avoid permanently blocked prompts. - Keep the VAPID private key on the server; never ship it to the client bundle.
- Delete subscriptions on the backend when the push service returns
404or410. - Store the destination route in the notification’s
datafield sonotificationClickscan deep-link the user. - Offer an in-app toggle that calls
unsubscribe()and removes the record server-side, respecting user choice. - Test against a production build (
ng buildthen serve) since the service worker never registers underng serve.