Swoff Swoff

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:

  1. Push API (SW) — Receive push messages from your backend
  2. 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 page

Browser Support

BrowserPush APINotifications API
Chrome✅ Yes✅ Yes
Firefox✅ Yes✅ Yes
Safari✅ Yes (16.1+)✅ Yes
Edge✅ Yes✅ Yes

Prerequisites

  1. SW Template — you'll add push and notificationclick event handlers
  2. Client Registration — SW must be registered
  3. VAPID keys — Your backend generates these (see VAPID Setup)
  4. 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 handlers

Client: Subscription Management

Create swoff/push.js:

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:

swoff/sw-template.js (appended)
// 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

BlockLocationPurpose
self.addEventListener("push", ...)Anywhere in fileReceive push → display notification
self.addEventListener("notificationclick", ...)Anywhere in fileHandle 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-keys
Public 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

hooks/usePushSubscription.js
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

composables/usePushSubscription.js
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

EventDetailWhen
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.js with subscription management
  • Add push and notificationclick event handlers to swoff/sw-template.js
  • Add a "Enable Notifications" button that calls subscribeToPush() + sends subscription to backend
  • Add an unsubscribe option in settings
  • (Optional) Add showLocalNotification() on mutation-sync-complete for sync feedback
  • (Optional) Implement usePushSubscription hook/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 push event handler with notification display
  • SW notificationclick event 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

On this page