Self-Hosted Push Notifications Specification
Part 4: Service Worker & PWA Configuration
Version: 1.0
Last Updated: October 2025
Prerequisites: Part 3: Frontend Implementation
Author: Bunty9
License: MIT (Free to use and adapt)
Table of Contents
- Service Worker Fundamentals
- Complete Service Worker Implementation
- PWA Manifest Configuration
- Notification Customization
- Action Handlers
- Background Sync
- Offline Support
- Testing Service Workers
- Debugging Tools
- Common Pitfalls
Service Worker Fundamentals
What is a Service Worker?
A service worker is a JavaScript file that runs in the background, separate from your web page. It enables features like:
- Push Notifications - Receive notifications when app is closed
- Offline Support - Cache resources for offline access
- Background Sync - Sync data when connection is restored
- Intercept Network Requests - Cache strategies, offline fallbacks
Service Worker Lifecycle
┌──────────────┐
│ INSTALL │ Triggered when SW file changes
└──────┬───────┘
│
▼
┌──────────────┐
│ ACTIVATE │ Clean up old caches
└──────┬───────┘
│
▼
┌──────────────┐
│ IDLE │ Waiting for events
└──────┬───────┘
│
▼
┌──────────────┐
│ FETCH / │ Handle events as they occur
│ PUSH / │
│ SYNC │
└──────┬───────┘
│
└─────────> Back to IDLE or TERMINATE
Browser Compatibility
Browser | Service Worker | Push API | Notification API |
---|---|---|---|
Chrome | ✅ 40+ | ✅ 42+ | ✅ 42+ |
Firefox | ✅ 44+ | ✅ 44+ | ✅ 44+ |
Safari | ✅ 11.1+ | ✅ 16+ (macOS only) | ⚠️ Limited on iOS |
Edge | ✅ 17+ | ✅ 17+ | ✅ 17+ |
Important: iOS Safari requires the PWA to be installed to home screen for push notifications to work.
Complete Service Worker Implementation
public/sw.js
This is a production-ready service worker with enhanced notification handling.
// ==================== SERVICE WORKER VERSION ====================
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = `ohhspaces-cache-${CACHE_VERSION}`;
// ==================== PUSH EVENT HANDLER ====================
self.addEventListener('push', function (event) {
console.log('[Service Worker] Push event received');
if (event.data) {
try {
const data = event.data.json();
console.log('[Service Worker] Push data:', data);
// Extract notification type
const notificationType = data.type || data.data?.type || 'general';
const options = createNotificationOptions(data, notificationType);
event.waitUntil(
Promise.all([
showTargetedNotification(
data.title || 'OhhSpaces Notification',
options,
notificationType
),
playNotificationSound(notificationType),
logNotificationEvent(data, 'received')
])
);
} catch (error) {
console.error('[Service Worker] Error parsing push notification:', error);
// Fallback notification
const options = createFallbackOptions();
event.waitUntil(
self.registration.showNotification('OhhSpaces Notification', options)
);
}
}
});
// ==================== NOTIFICATION OPTIONS ====================
/**
* Creates notification options based on type and data
* @param {Object} data - Notification payload from backend
* @param {string} type - Notification type (booking_confirmed, etc.)
* @returns {Object} Notification options
*/
function createNotificationOptions(data, type) {
const baseOptions = {
body: data.body || 'You have a new notification',
icon: data.icon || '/icon-192x192.png',
badge: '/icon-72x72.png',
data: {
url: data.url || '/',
dateOfArrival: Date.now(),
primaryKey: data.data?.id || '1',
type: type,
...data.data
},
requireInteraction: false,
silent: false, // CRITICAL: Must be false for system sound
vibrate: [200, 100, 200]
};
// Customize based on notification type
switch (type) {
case 'booking_confirmed':
case 'booking_reserved':
return {
...baseOptions,
vibrate: [100, 50, 100, 50, 100],
actions: [
{
action: 'view_booking',
title: 'View Booking',
icon: '/icon-72x72.png'
},
{
action: 'dismiss',
title: 'Dismiss',
icon: '/icon-72x72.png'
}
],
requireInteraction: true,
tag: 'booking-confirmation'
};
case 'booking_reminder_checkin':
case 'booking_reminder_checkout':
return {
...baseOptions,
vibrate: [200, 100, 200],
actions: [
{
action: 'view_booking',
title: 'View Details',
icon: '/icon-72x72.png'
},
{
action: 'snooze',
title: 'Remind Later',
icon: '/icon-72x72.png'
}
],
requireInteraction: true,
tag: 'booking-reminder',
renotify: true
};
case 'booking_cancelled':
return {
...baseOptions,
vibrate: [300, 200, 300],
actions: [
{
action: 'view_refund',
title: 'View Refund',
icon: '/icon-72x72.png'
},
{
action: 'dismiss',
title: 'OK',
icon: '/icon-72x72.png'
}
],
tag: 'booking-cancelled'
};
case 'refund_processed':
return {
...baseOptions,
vibrate: [100, 50, 100],
actions: [
{
action: 'view_payment',
title: 'View Details',
icon: '/icon-72x72.png'
},
{
action: 'dismiss',
title: 'OK',
icon: '/icon-72x72.png'
}
],
tag: 'refund-processed'
};
case 'seat_assigned':
case 'seat_auto_assigned':
case 'seat_reassigned':
return {
...baseOptions,
vibrate: [100, 50, 100],
actions: [
{
action: 'view_booking',
title: 'View Seat',
icon: '/icon-72x72.png'
},
{
action: 'dismiss',
title: 'OK',
icon: '/icon-72x72.png'
}
],
tag: 'seat-assignment'
};
case 'seat_removed':
return {
...baseOptions,
vibrate: [200, 100, 200],
actions: [
{
action: 'view_bookings',
title: 'View Bookings',
icon: '/icon-72x72.png'
},
{
action: 'dismiss',
title: 'OK',
icon: '/icon-72x72.png'
}
],
tag: 'seat-removed'
};
case 'beverage_order_new':
case 'beverage_completed':
case 'beverage_cancelled':
return {
...baseOptions,
vibrate: [100, 50, 100],
actions: [
{
action: 'view_order',
title: 'View Order',
icon: '/icon-72x72.png'
},
{
action: 'dismiss',
title: 'OK',
icon: '/icon-72x72.png'
}
],
tag: 'beverage-order'
};
default:
return {
...baseOptions,
vibrate: [100, 50, 100],
actions: [
{
action: 'open',
title: 'Open',
icon: '/icon-72x72.png'
},
{
action: 'dismiss',
title: 'Dismiss',
icon: '/icon-72x72.png'
}
]
};
}
}
/**
* Creates fallback notification options
*/
function createFallbackOptions() {
return {
body: 'You have a new notification from OhhSpaces',
icon: '/icon-192x192.png',
badge: '/icon-72x72.png',
vibrate: [100, 50, 100],
data: {
url: '/',
dateOfArrival: Date.now(),
primaryKey: '1'
}
};
}
// ==================== NOTIFICATION DISPLAY ====================
/**
* Show notification with targeted behavior
*/
async function showTargetedNotification(title, options, type) {
// Replace existing notifications of the same type if needed
if (options.tag) {
const notifications = await self.registration.getNotifications({
tag: options.tag
});
notifications.forEach(notification => {
if (!options.renotify) {
notification.close();
}
});
}
// Show the notification
return self.registration.showNotification(title, options);
}
// ==================== SOUND HANDLING ====================
/**
* Play notification sound by sending message to client
*/
async function playNotificationSound(type) {
try {
// Get all window clients
const allClients = await clients.matchAll({
includeUncontrolled: true,
type: 'window'
});
// Send message to all clients to play sound
allClients.forEach(client => {
client.postMessage({
type: 'PLAY_NOTIFICATION_SOUND',
notificationType: type
});
});
// If no clients are open, system sound will play
return Promise.resolve();
} catch (error) {
console.error('[Service Worker] Error playing sound:', error);
return Promise.resolve();
}
}
// ==================== NOTIFICATION CLICK HANDLER ====================
self.addEventListener('notificationclick', function (event) {
console.log('[Service Worker] Notification clicked:', event.action);
event.notification.close();
const notificationData = event.notification.data;
const notificationType = notificationData.type || 'general';
// Handle different actions based on notification type
event.waitUntil(
handleNotificationAction(event.action, notificationData, notificationType)
);
});
/**
* Handle different notification actions
*/
async function handleNotificationAction(action, data, type) {
let urlToOpen = data.url || '/';
// Handle type-specific actions
switch (action) {
case 'view_booking':
urlToOpen = `/bookings/${data.bookingId || data.booking_id || ''}`;
break;
case 'view_bookings':
urlToOpen = '/bookings';
break;
case 'view_refund':
case 'view_payment':
urlToOpen = `/bookings/${data.bookingId || data.booking_id || ''}`;
break;
case 'view_order':
urlToOpen = '/beverages';
break;
case 'snooze':
// Handle snooze by storing preference
await handleSnoozeAction(data);
return; // Don't open any window
case 'dismiss':
case 'close':
// Just close, don't open anything
await logNotificationEvent(data, 'dismissed');
return;
default:
// Default action or no action specified
urlToOpen = data.url || '/';
}
// Log action
await logNotificationEvent(data, action || 'opened');
// Open or focus the appropriate window
return handleWindowOpen(urlToOpen);
}
/**
* Handle snooze functionality
*/
async function handleSnoozeAction(data) {
const snoozeTime = 15 * 60 * 1000; // 15 minutes
// Store snooze preference in IndexedDB or notify backend
console.log('[Service Worker] Snoozed notification for 15 minutes');
// You could implement backend notification here
// await fetch('/api/notifications/snooze', {
// method: 'POST',
// body: JSON.stringify({ notificationId: data.primaryKey, snoozeTime })
// });
}
/**
* Handle opening/focusing windows
*/
async function handleWindowOpen(urlToOpen) {
const clientList = await clients.matchAll({
type: 'window',
includeUncontrolled: true
});
// Check if there is already a window/tab open with the target URL
for (let i = 0; i < clientList.length; i++) {
const client = clientList[i];
if (client.url.includes(urlToOpen) && 'focus' in client) {
return client.focus();
}
}
// Check if any window is open to the app
for (let i = 0; i < clientList.length; i++) {
const client = clientList[i];
if (client.url.includes(self.location.origin) && 'navigate' in client) {
client.focus();
return client.navigate(urlToOpen);
}
}
// If not, then open the target URL in a new window/tab
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
}
// ==================== NOTIFICATION CLOSE HANDLER ====================
self.addEventListener('notificationclose', function (event) {
console.log('[Service Worker] Notification closed');
const notificationData = event.notification.data;
event.waitUntil(logNotificationEvent(notificationData, 'closed'));
});
// ==================== LOGGING ====================
/**
* Log notification events for analytics
*/
async function logNotificationEvent(data, action) {
try {
// You can send logs to your backend for analytics
// await fetch('/api/notifications/log', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// notificationId: data.primaryKey,
// type: data.type,
// action: action,
// timestamp: Date.now()
// })
// });
console.log(`[Service Worker] Notification ${action}:`, data.type);
} catch (error) {
console.error('[Service Worker] Failed to log event:', error);
}
}
// ==================== BACKGROUND SYNC ====================
self.addEventListener('sync', function (event) {
console.log('[Service Worker] Background sync:', event.tag);
if (event.tag === 'sync-notifications') {
event.waitUntil(doBackgroundSync());
}
});
/**
* Perform background sync operations
*/
async function doBackgroundSync() {
try {
// Sync pending actions when connection is restored
console.log('[Service Worker] Performing background sync');
// Example: Fetch missed notifications
// const response = await fetch('/api/notifications/missed');
// const data = await response.json();
// Show missed notifications...
return Promise.resolve();
} catch (error) {
console.error('[Service Worker] Background sync failed:', error);
return Promise.reject(error);
}
}
// ==================== INSTALL & ACTIVATE ====================
self.addEventListener('install', function (event) {
console.log('[Service Worker] Installing version', CACHE_VERSION);
// Skip waiting to activate immediately
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll([
'/',
'/icon-192x192.png',
'/icon-512x512.png',
'/icon-72x72.png'
]);
})
);
});
self.addEventListener('activate', function (event) {
console.log('[Service Worker] Activating version', CACHE_VERSION);
event.waitUntil(
Promise.all([
// Claim all clients immediately
clients.claim(),
// Clean up old caches
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
console.log('[Service Worker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
])
);
});
// ==================== FETCH HANDLER (Optional) ====================
self.addEventListener('fetch', function (event) {
// Optional: Add caching strategies here
// For now, just use network-first approach
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request);
})
);
});
PWA Manifest Configuration
public/manifest.json
A complete PWA manifest for installability and enhanced user experience.
{
"name": "Your App Name",
"short_name": "YourApp",
"description": "Your app description - Book, manage, and stay connected with push notifications",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#006FEE",
"background_color": "#ffffff",
"lang": "en",
"dir": "ltr",
"categories": ["business", "productivity"],
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "2880x1800",
"type": "image/png",
"form_factor": "wide",
"label": "Desktop view"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile view"
}
],
"shortcuts": [
{
"name": "Book Now",
"short_name": "Book",
"description": "Quickly book a space",
"url": "/?shortcut=book",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192"
}
]
},
{
"name": "My Bookings",
"short_name": "Bookings",
"description": "View your bookings",
"url": "/bookings?shortcut=my",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192"
}
]
}
],
"prefer_related_applications": false
}
Manifest Fields Explained
Field | Purpose | Required |
---|---|---|
name |
Full app name (45 chars max) | ✅ |
short_name |
Short name for home screen (12 chars) | ✅ |
start_url |
URL to open when launched | ✅ |
display |
Display mode (standalone, fullscreen, minimal-ui) | ✅ |
icons |
App icons for different sizes | ✅ |
theme_color |
Browser UI color | Recommended |
background_color |
Splash screen color | Recommended |
orientation |
Default orientation | Optional |
shortcuts |
Quick actions from home screen | Optional |
screenshots |
For app stores | Optional |
Notification Customization
Notification Options Reference
const notificationOptions = {
// Basic Properties
body: 'Notification body text', // Main message
icon: '/icon-192x192.png', // Large icon (192x192)
badge: '/icon-72x72.png', // Small icon in status bar (72x72)
image: '/notification-image.jpg', // Large image below text
// Behavior
requireInteraction: false, // Keep until user dismisses
silent: false, // Play system sound (true = silent)
vibrate: [200, 100, 200], // Vibration pattern [vibrate, pause, vibrate]
renotify: false, // Re-alert on same tag update
// Grouping
tag: 'booking-123', // Unique tag (replaces previous with same tag)
// Data
data: { // Custom data passed to click handler
url: '/bookings/123',
bookingId: '123',
userId: 'abc'
},
// Actions (max 2 on mobile, 4 on desktop)
actions: [
{
action: 'view',
title: 'View Details',
icon: '/action-icon.png'
},
{
action: 'dismiss',
title: 'Dismiss',
icon: '/dismiss-icon.png'
}
],
// Advanced
timestamp: Date.now(), // When event occurred
dir: 'auto', // Text direction (ltr, rtl, auto)
lang: 'en', // Language code
};
Vibration Patterns
// Short tap
vibrate: [100]
// Double tap
vibrate: [100, 50, 100]
// Triple tap
vibrate: [100, 50, 100, 50, 100]
// Long vibration
vibrate: [500]
// SOS pattern
vibrate: [100, 30, 100, 30, 100, 200, 200, 30, 200, 30, 200, 200, 100, 30, 100, 30, 100]
// Custom rhythm
vibrate: [200, 100, 200, 100, 300]
Action Handlers
Custom Action Implementation
// In service worker
self.addEventListener('notificationclick', function (event) {
event.notification.close();
const action = event.action;
const data = event.notification.data;
event.waitUntil(
handleCustomActions(action, data)
);
});
async function handleCustomActions(action, data) {
switch (action) {
case 'accept':
// Accept booking request
await fetch('/api/bookings/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookingId: data.bookingId })
});
// Show confirmation notification
await self.registration.showNotification('Booking Accepted', {
body: 'You accepted the booking request',
icon: '/success-icon.png'
});
break;
case 'reject':
// Reject booking request
await fetch('/api/bookings/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookingId: data.bookingId })
});
break;
case 'reply':
// Open compose message
return clients.openWindow(`/messages/compose?to=${data.userId}`);
default:
// Default: open app
return clients.openWindow(data.url || '/');
}
}
Background Sync
Registering Background Sync
// In your frontend code
async function registerBackgroundSync() {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('sync-notifications');
console.log('Background sync registered');
} catch (error) {
console.error('Background sync failed:', error);
}
}
Handling Background Sync in Service Worker
self.addEventListener('sync', function (event) {
if (event.tag === 'sync-notifications') {
event.waitUntil(syncNotifications());
}
});
async function syncNotifications() {
try {
// Fetch missed notifications when back online
const response = await fetch('/api/notifications/missed');
const notifications = await response.json();
// Show each missed notification
for (const notif of notifications) {
await self.registration.showNotification(notif.title, {
body: notif.body,
icon: notif.icon,
data: notif.data
});
}
return Promise.resolve();
} catch (error) {
// Retry later
return Promise.reject(error);
}
}
Offline Support
Cache Strategies
// In service worker - fetch event
self.addEventListener('fetch', function (event) {
const url = new URL(event.request.url);
// API requests: Network first, cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
// Clone and cache successful responses
if (response.ok) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseToCache);
});
}
return response;
})
.catch(() => {
// Return cached version if available
return caches.match(event.request);
})
);
return;
}
// Static assets: Cache first, network fallback
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request);
})
);
});
Testing Service Workers
Chrome DevTools Testing
- Open DevTools → Application → Service Workers
- Check "Update on reload" - Forces SW update on page refresh
- Unregister - Remove service worker completely
- Push - Simulate push event (requires subscription)
Manual Push Test
// In browser console
navigator.serviceWorker.ready.then(registration => {
registration.showNotification('Test Notification', {
body: 'This is a test',
icon: '/icon-192x192.png',
data: { url: '/' }
});
});
Automated Testing
// test-service-worker.js
const { test, expect } = require('@playwright/test');
test('service worker registers successfully', async ({ page }) => {
await page.goto('http://localhost:3000');
const swRegistered = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return registration.active !== null;
});
expect(swRegistered).toBe(true);
});
test('service worker shows notification', async ({ page, context }) => {
await context.grantPermissions(['notifications']);
await page.goto('http://localhost:3000');
const notificationShown = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification('Test', {
body: 'Test body'
});
const notifications = await registration.getNotifications();
return notifications.length > 0;
});
expect(notificationShown).toBe(true);
});
Debugging Tools
Service Worker Debugging Checklist
# 1. Check if service worker is registered
# Browser Console:
navigator.serviceWorker.getRegistrations().then(regs => console.log(regs));
# 2. Check service worker state
# Browser Console:
navigator.serviceWorker.ready.then(reg => console.log(reg.active.state));
# 3. Force update service worker
# Browser Console:
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(reg => reg.update());
});
# 4. Unregister all service workers
# Browser Console:
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(reg => reg.unregister());
});
Common Console Commands
// Check if push supported
console.log('Push supported:', 'PushManager' in window);
// Check notification permission
console.log('Notification permission:', Notification.permission);
// Get current subscription
navigator.serviceWorker.ready.then(async reg => {
const sub = await reg.pushManager.getSubscription();
console.log('Current subscription:', sub);
});
// Check service worker controller
console.log('Controlled by SW:', navigator.serviceWorker.controller);
Common Pitfalls
Issue 1: Service Worker Not Updating
Problem: Changes to sw.js
not reflected.
Solution:
// Force update on page load
navigator.serviceWorker.register('/sw.js').then(reg => {
reg.update();
});
// In service worker, use versioning
const CACHE_VERSION = 'v1.0.1'; // Increment on changes
Issue 2: Notifications Not Showing
Problem: showNotification()
called but nothing appears.
Solution:
// ALWAYS check permission first
if (Notification.permission === 'granted') {
registration.showNotification('Title', options);
} else {
console.error('Permission not granted');
}
// Ensure `silent: false` in options
const options = {
silent: false // System sound will play
};
Issue 3: Service Worker Scope Issues
Problem: Service worker not controlling pages.
Solution:
// Register at root for widest scope
navigator.serviceWorker.register('/sw.js', { scope: '/' });
// NOT:
navigator.serviceWorker.register('/js/sw.js'); // Limited to /js/*
Issue 4: iOS Safari Not Working
Problem: Push notifications don't work on iPhone.
Solution:
- User MUST add app to home screen (PWA)
- Open app from home screen icon (not browser)
- Only works on iOS 16.4+ with Safari 16.4+
- macOS Safari 16+ works normally
Detection Code:
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = window.navigator.standalone;
if (isIOS && !isStandalone) {
// Show "Add to Home Screen" instructions
alert('Please add this app to your home screen to enable notifications');
}
Summary
You now have:
✅ Complete service worker with push event handling
✅ PWA manifest for installability
✅ Custom notification options and actions
✅ Background sync implementation
✅ Offline support with caching
✅ Comprehensive testing guide
✅ Debugging tools and commands
✅ Common pitfall solutions
Next Steps
➡️ Part 5: Advanced Features & Production Deployment
Part 5 will cover:
- Advanced targeting strategies
- Notification scheduling
- Rate limiting and throttling
- Production deployment (Docker, Kubernetes)
- SSL/HTTPS configuration
- Load balancing
- Horizontal scaling
- Database optimization
Top comments (0)