Push Notifications
Subscribe to push, handle notifications in the service worker, and integrate with your data layer.
Push Notifications
Status: Available Pattern
Push API + Notifications API let you send messages to users even when the app is closed.
What are Push Notifications?
Two separate browser APIs that work together:
- Push API (SW) — Receive push messages from your backend
- Notifications API (Client + SW) — Display notifications to the user
Your backend sends the push message — Swoff covers the client-side subscription management and SW handlers.
Flow
Your Backend sends push message (with VAPID auth)
↓
Browser receives → wakes Service Worker
↓
SW fires 'push' event
↓
SW shows notification via Notifications API
↓
User clicks → SW handles 'notificationclick'
↓
Open or focus the app, navigate to relevant pageBrowser Support
| Browser | Push API | Notifications API |
|---|---|---|
| Chrome | ✅ Yes | ✅ Yes |
| Firefox | ✅ Yes | ✅ Yes |
| Safari | ✅ Yes (16.1+) | ✅ Yes |
| Edge | ✅ Yes | ✅ Yes |
Prerequisites
- SW Template — you'll add
pushandnotificationclickevent handlers - Client Registration — SW must be registered
- VAPID keys — Your backend generates these (see VAPID Setup)
- HTTPS — Push API requires a secure context (localhost works in development)
Implementation
File Structure
Add to your project:
swoff/
├── push.js ← [NEW] Subscription management
├── sw-template.js ← Add push + notificationclick handlersClient: Subscription Management
Create swoff/push.js:
const SUBSCRIPTION_DB = "swoff-push";
const SUBSCRIPTION_STORE = "subscription";
let permissionState = Notification.permission;
function openPushDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(SUBSCRIPTION_DB, 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(SUBSCRIPTION_STORE)) {
db.createObjectStore(SUBSCRIPTION_STORE, { keyPath: "id" });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
/**
* Request notification permission from the user.
* @returns {Promise<boolean>} Whether permission was granted
*/
export async function requestNotificationPermission() {
if (permissionState === "granted") return true;
if (permissionState === "denied") return false;
const result = await Notification.requestPermission();
permissionState = result;
window.dispatchEvent(
new CustomEvent("push-permission-changed", { detail: { permission: result } }),
);
return result === "granted";
}
/**
* Get the current push subscription if one exists.
* @returns {Promise<PushSubscription|null>}
*/
export async function getPushSubscription() {
try {
const registration = await navigator.serviceWorker.ready;
return await registration.pushManager.getSubscription();
} catch {
return null;
}
}
/**
* Subscribe to push notifications.
* @param {string} vapidPublicKey - Your backend's VAPID public key
* @returns {Promise<PushSubscription|null>}
*/
export async function subscribeToPush(vapidPublicKey) {
const granted = await requestNotificationPermission();
if (!granted) return null;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
// Persist subscription locally
const db = await openPushDB();
const tx = db.transaction(SUBSCRIPTION_STORE, "readwrite");
tx.objectStore(SUBSCRIPTION_STORE).put({
id: "current",
endpoint: subscription.endpoint,
keys: subscription.toJSON().keys,
subscribedAt: Date.now(),
});
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
window.dispatchEvent(
new CustomEvent("push-subscription-changed", { detail: { subscribed: true } }),
);
return subscription;
}
/**
* Unsubscribe from push notifications.
*/
export async function unsubscribeFromPush() {
const subscription = await getPushSubscription();
if (!subscription) return;
await subscription.unsubscribe();
const db = await openPushDB();
const tx = db.transaction(SUBSCRIPTION_STORE, "readwrite");
tx.objectStore(SUBSCRIPTION_STORE).delete("current");
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
window.dispatchEvent(
new CustomEvent("push-subscription-changed", { detail: { subscribed: false } }),
);
}
/**
* Check whether the user is subscribed to push notifications.
* @returns {Promise<boolean>}
*/
export async function isSubscribed() {
const sub = await getPushSubscription();
return sub !== null;
}
/**
* Convert a base64-encoded VAPID public key to a Uint8Array.
* Required by pushManager.subscribe().
*/
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
return Uint8Array.from(rawData.split("").map((c) => c.charCodeAt(0)));
}Client: Send Subscription to Your Backend
You need to send the subscription object to your backend so it can send push messages:
import { subscribeToPush } from "./swoff/push.js";
async function enablePushNotifications(vapidPublicKey) {
const subscription = await subscribeToPush(vapidPublicKey);
if (!subscription) return;
// Send subscription to YOUR backend (Go, Node.js, Python, etc.)
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
});
}Note: subscription.toJSON() returns { endpoint, keys: { p256dh, auth } }. Your backend stores this and uses it to send push messages. Swoff doesn't provide backend code.
Client: Unsubscribe from Your Backend
import { unsubscribeFromPush, getPushSubscription } from "./swoff/push.js";
async function disablePushNotifications() {
const subscription = await getPushSubscription();
if (!subscription) return;
const json = subscription.toJSON();
// Tell your backend to stop sending
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: json.endpoint }),
});
await unsubscribeFromPush();
}Service Worker: Handle Push Events
Add these handlers to swoff/sw-template.js:
// Push notification — receive and display
self.addEventListener("push", (event) => {
let data;
try {
data = event.data ? event.data.json() : {};
} catch {
data = { title: "New Update", body: event.data?.text() || "" };
}
const options = {
body: data.body || "",
icon: data.icon || "/icon-192.png",
badge: data.badge || "/badge.png",
image: data.image || undefined,
vibrate: data.vibrate || [200, 100, 200],
data: {
url: data.url || "/",
...(data.data || {}),
},
actions: data.actions || [],
tag: data.tag || undefined,
requireInteraction: data.requireInteraction || false,
};
event.waitUntil(self.registration.showNotification(data.title || "Update", options));
});
// Notification click — open or focus the app
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
const action = event.action;
event.waitUntil(
(async () => {
// If an action was clicked, handle it
if (action) {
// Your custom action handling here
// e.g., "reply" → open chat, "dismiss" → do nothing
}
const clients = await self.clients.matchAll({ type: "window" });
// Focus existing tab if found
for (const client of clients) {
const clientUrl = new URL(client.url);
const targetUrl = new URL(url, self.location.origin);
if (clientUrl.pathname === targetUrl.pathname && "focus" in client) {
return client.focus();
}
}
// Otherwise open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})(),
);
});SW Template Changes Summary
| Block | Location | Purpose |
|---|---|---|
self.addEventListener("push", ...) | Anywhere in file | Receive push → display notification |
self.addEventListener("notificationclick", ...) | Anywhere in file | Handle click → focus/open app |
No changes needed to the existing fetch or message handlers.
VAPID Keys (Your Backend Setup)
Your backend needs VAPID keys to send push messages. This is not Swoff code — you handle this with your backend stack.
# Example: generate keys with Node.js
# Your backend can use any language — Go, Python, Rust, etc.
npm install web-push
npx web-push generate-vapid-keysPublic Key: BEr38w... (put this in your client code)
Private Key: EC19nW... (keep this on your server)The public key goes into your client code (it's meant to be public). The private key stays on your server.
// Your client config
const VAPID_PUBLIC_KEY = "BEr38w...";Local Notifications
Push notifications require a backend. If you just want to show notifications from client code, use the Notifications API directly:
export function showLocalNotification(title, options = {}) {
if (Notification.permission !== "granted") return;
new Notification(title, {
body: options.body || "",
icon: "/icon-192.png",
...options,
});
}This is useful for notifying the user about client-side events like sync completion.
Data Layer Integration
Tie notifications to mutation sync events so the user knows when offline writes have been processed:
import { showLocalNotification } from "./swoff/push.js";
// Show notification when background sync completes
window.addEventListener("mutation-sync-complete", (event) => {
const { succeeded, failed } = event.detail;
const total = succeeded + failed;
if (total === 0) return;
const body = succeeded > 0
? `${succeeded} item${succeeded > 1 ? "s" : ""} synced successfully`
: "Sync completed";
if (failed > 0) {
showLocalNotification("Sync completed with errors", {
body: `${failed} item${failed > 1 ? "s" : ""} failed to sync`,
});
} else if (succeeded > 0) {
showLocalNotification("Sync complete", { body });
}
});
// Show notification when new SW version is available
window.addEventListener("sw-update-available", (event) => {
showLocalNotification("Update available", {
body: `Version ${event.detail.version} is ready to install`,
});
});Framework Adapters
React
import { useState, useEffect, useCallback } from "react";
import {
requestNotificationPermission,
subscribeToPush,
unsubscribeFromPush,
isSubscribed,
getPushSubscription,
} from "../swoff/push.js";
export function usePushSubscription(vapidPublicKey) {
const [permission, setPermission] = useState(Notification.permission);
const [subscribed, setSubscribed] = useState(false);
const [subscription, setSubscription] = useState(null);
useEffect(() => {
async function init() {
setSubscribed(await isSubscribed());
setSubscription(await getPushSubscription());
}
init();
const onPermission = (e) => setPermission(e.detail.permission);
const onSubscription = (e) => {
setSubscribed(e.detail.subscribed);
if (!e.detail.subscribed) setSubscription(null);
};
window.addEventListener("push-permission-changed", onPermission);
window.addEventListener("push-subscription-changed", onSubscription);
return () => {
window.removeEventListener("push-permission-changed", onPermission);
window.removeEventListener("push-subscription-changed", onSubscription);
};
}, []);
const subscribe = useCallback(async () => {
const sub = await subscribeToPush(vapidPublicKey);
setSubscription(sub);
return sub;
}, [vapidPublicKey]);
const unsubscribe = useCallback(async () => {
await unsubscribeFromPush();
setSubscription(null);
}, []);
return {
permission,
subscribed,
subscription,
subscribe,
unsubscribe,
isSupported: "serviceWorker" in navigator && "PushManager" in window,
};
}Vue
import { ref, onMounted, onUnmounted, computed } from "vue";
import {
subscribeToPush,
unsubscribeFromPush,
isSubscribed,
getPushSubscription,
} from "../swoff/push.js";
export function usePushSubscription(vapidPublicKey) {
const permission = ref(Notification.permission);
const subscribed = ref(false);
const subscription = ref(null);
const isSupported = computed(
() => "serviceWorker" in navigator && "PushManager" in window,
);
async function init() {
subscribed.value = await isSubscribed();
subscription.value = await getPushSubscription();
}
onMounted(() => {
init();
window.addEventListener("push-permission-changed", (e) => {
permission.value = e.detail.permission;
});
window.addEventListener("push-subscription-changed", async (e) => {
subscribed.value = e.detail.subscribed;
if (!e.detail.subscribed) subscription.value = null;
});
});
onUnmounted(() => {
window.removeEventListener("push-permission-changed", () => {});
window.removeEventListener("push-subscription-changed", () => {});
});
async function subscribe() {
const sub = await subscribeToPush(vapidPublicKey);
subscription.value = sub;
return sub;
}
async function unsubscribe() {
await unsubscribeFromPush();
subscription.value = null;
}
return { permission, subscribed, subscription, subscribe, unsubscribe, isSupported };
}Window Events
| Event | Detail | When |
|---|---|---|
push-permission-changed | { permission: string } | User grants or denies permission |
push-subscription-changed | { subscribed: boolean } | User subscribes or unsubscribes |
Integration Checklist
- Your backend generates VAPID keys — store public key in client, private key on server
- Create
swoff/push.jswith subscription management - Add
pushandnotificationclickevent handlers toswoff/sw-template.js - Add a "Enable Notifications" button that calls
subscribeToPush()+ sends subscription to backend - Add an unsubscribe option in settings
- (Optional) Add
showLocalNotification()onmutation-sync-completefor sync feedback - (Optional) Implement
usePushSubscriptionhook/composable for your framework - Test in Chrome, Firefox, Safari (16.1+)
- Your backend sends push messages — you build that part
Status
- Push permission request wrapper
- Subscribe/unsubscribe management with IndexedDB persistence
- SW
pushevent handler with notification display - SW
notificationclickevent handler with focus/open - VAPID key setup guide (client-side usage)
- Data layer integration (sync complete notifications)
- Local notification helper
- Framework adapters (React hook, Vue composable)
- Window events for subscription state changes
Next Steps
- Background Sync — another SW feature
- SW Template — notification handling in the SW
Swoff