Blog / PWA · JavaScript · Web Push API

Push notificaties implementeren in een PWA met de Web Push API

P
Pim vd Molen

Push notificaties zijn een van die features die een progressive web app echt het gevoel geven van een native applicatie. Toch zie ik in de praktijk dat veel developers ze vermijden, niet omdat het onmogelijk is, maar omdat de combinatie van de Web Push API, VAPID-authenticatie en service workers op het eerste gezicht overweldigend aanvoelt. Gelukkig valt het reuze mee als je het stap voor stap aanpakt, en in dit artikel loop ik door de volledige implementatie heen op basis van wat ik zelf in productie draai.

VAPID-sleutels genereren en de subscription opslaan

Het startpunt van elke push notificatie setup is een geldig VAPID-sleutelpaar. VAPID staat voor Voluntary Application Server Identification en zorgt ervoor dat een pushservice weet welke server een notificatie verstuurt. Je genereert dit sleutelpaar eenmalig en slaat het op als omgevingsvariabele.

npx web-push generate-vapid-keys

Dit geeft een publieke en privésleutel terug. De publieke sleutel heb je nodig in de frontend om een subscription aan te maken, de privésleutel gebruik je server-side om de berichten te ondertekenen. Bewaar de privésleutel nooit in je versiebeheersysteem.

Op de client vraag je eerst toestemming aan de gebruiker, en als die toestemming er is, maak je via de browser een PushSubscription-object aan. Dat object bevat het endpoint en de cryptografische sleutels die de browser nodig heeft om berichten te kunnen ontcijferen.

async function subscribeToPush() {
    const registration = await navigator.serviceWorker.ready;

    const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
    });

    await fetch('/api/push/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(subscription)
    });
}

De helperfunctie urlBase64ToUint8Array converteert de Base64-gecodeerde VAPID-sleutel naar een Uint8Array, wat de browser verwacht. Die functie schrijf je zelf of je pakt hem uit de standaard voorbeelden van de Web Push-specificatie. Op de server sla je het subscription-object op in de database, gekoppeld aan de ingelogde gebruiker.

Notificaties versturen vanuit de backend

Met het subscription-object in handen stuur je vanuit de server berichten via de pushservice van de browser. Ik doe dit in Node.js met het web-push-pakket, maar er zijn ook bibliotheken beschikbaar voor PHP, Python en andere talen.

const webpush = require('web-push');

webpush.setVapidDetails(
    'mailto:[email protected]',
    process.env.VAPID_PUBLIC_KEY,
    process.env.VAPID_PRIVATE_KEY
);

async function sendNotification(subscription, payload) {
    try {
        await webpush.sendNotification(subscription, JSON.stringify(payload));
    } catch (error) {
        if (error.statusCode === 410) {
            // Subscription is verlopen, verwijder hem uit de database
            await removeSubscription(subscription.endpoint);
        }
    }
}

Let op die 410-statuscode. Browsers sturen die terug als een subscription niet meer geldig is, bijvoorbeeld omdat de gebruiker notificaties heeft uitgeschakeld of de browser opnieuw heeft geïnstalleerd. Als je die afhandeling weglaat, bouw je een database vol met dode subscriptions op. Dat kost niet alleen opslag, maar vertraagt ook elk volgend verzendproces.

De payload die je meestuurt is een vrij te bepalen JSON-object. Ik houd dat object klein en stuur alleen een titel, een berichttekst en optioneel een URL waarnaar de gebruiker kan navigeren als hij op de notificatie klikt. Grotere data haal je later op in de service worker als dat nodig is.

De service worker afhandelen

Een push notificatie komt binnen in de service worker, niet in de applicatie zelf. De browser wekt de service worker op zodra er een bericht binnenkomt, ook als de applicatie op dat moment niet open staat. Dit is precies wat push notificaties onderscheidt van gewone in-app meldingen.

self.addEventListener('push', (event) => {
    const data = event.data ? event.data.json() : {};

    const title = data.title || 'Nieuwe melding';
    const options = {
        body: data.body || '',
        icon: '/icons/icon-192x192.png',
        badge: '/icons/badge-72x72.png',
        data: { url: data.url || '/' }
    };

    event.waitUntil(
        self.registration.showNotification(title, options)
    );
});

Cruciaal hier is event.waitUntil(). Zonder die aanroep kan de browser de service worker vroegtijdig beëindigen voordat de notificatie zichtbaar wordt. Je geeft de methode een Promise mee en de browser wacht tot die Promise is afgerond.

Het afhandelen van een klik op de notificatie doe je in een apart notificationclick-event. Daar open je het juiste venster of focus je een al geopend tabblad met de bijbehorende URL.

self.addEventListener('notificationclick', (event) => {
    event.notification.close();

    const urlToOpen = event.notification.data.url;

    event.waitUntil(
        clients.matchAll({ type: 'window', includeUncontrolled: true })
            .then((windowClients) => {
                const matchingClient = windowClients.find(
                    (client) => client.url === urlToOpen
                );

                if (matchingClient) {
                    return matchingClient.focus();
                }

                return clients.openWindow(urlToOpen);
            })
    );
});

Dit patroon zorgt ervoor dat je geen onnodige nieuwe tabs opent als de gebruiker de pagina al open heeft staan. Dat kleine detail maakt een groot verschil in de gebruikerservaring.

Browsercompatibiliteit en toestemmingsbeheer

Safari op iOS ondersteunt Web Push pas vanaf versie 16.4, en alleen als de PWA via "Voeg toe aan beginscherm" is geïnstalleerd. Dat betekent dat je altijd een fallback moet bouwen voor browsers die de Push API niet kennen. Een simpele controle voorkomt JavaScript-fouten bij niet-ondersteunde browsers.

if ('serviceWorker' in navigator && 'PushManager' in window) {
    document.getElementById('enable-notifications')
        .style.display = 'block';
}

Toestemmingsbeheer is iets waar ik behoorlijk zorgvuldig mee omga. Browsers blokkeren notificatiedialogen als een site die te agressief inzet, wat je domein op een soort zwarte lijst plaatst. Ik toon de vraag om toestemming altijd na een concrete actie van de gebruiker, bijvoorbeeld nadat hij zich heeft aangemeld voor een service of een specifieke voorkeur heeft aangegeven. Nooit direct bij het eerste bezoek.

async function requestPermission() {
    const permission = await Notification.requestPermission();

    if (permission === 'granted') {
        await subscribeToPush();
    } else if (permission === 'denied') {
        console.warn('Notificaties geweigerd door de gebruiker.');
    }
}

Browsers slaan de keuze van de gebruiker permanent op. Als iemand eenmaal op "weigeren" heeft geklikt, kan je applicatie die dialoog niet opnieuw tonen. De enige manier om dat te herstellen is via de browserinstellingen, iets wat de gemiddelde gebruiker niet zal doen. Dat maakt die eerste vraag om toestemming zo waardevol: je krijgt er maar één kans voor.

Wat ik zelf een prettige aanpak vind, is een eigen UI-laag bouwen die de gebruiker informeert over wat ze kunnen verwachten voordat de browser om toestemming vraagt. Een klein modaal venster met "We sturen je een melding als je bestelling klaarstaat, wil je dat inschakelen?" werkt beter dan een kale browserdialoog zonder context. De conversieratio ligt dan merkbaar hoger, en je hebt minder gebruikers die direct op "weigeren" klikken zonder nagedacht te hebben.

Push notificaties in een PWA zijn een van die features waarbij de technische implementatie relatief overzichtelijk is, maar de details in de gebruikerservaring het verschil maken. Zorg voor goede foutafhandeling op de server, houd je payloads klein en test altijd op een echt apparaat, want de browserontwikkeltools emuleren dit gedrag niet altijd perfect. De moeite is het waard: als het eenmaal staat, heb je een notificatiekanaal dat volledig in jouw controle zit, zonder afhankelijkheid van een extern platform.

// VEELGESTELDE VRAGEN