KEMBAR78
# Self-Hosted Push Notifications Part-4 - DEV Community

DEV Community

Bipin C
Bipin C

Posted on

# Self-Hosted Push Notifications Part-4

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

  1. Service Worker Fundamentals
  2. Complete Service Worker Implementation
  3. PWA Manifest Configuration
  4. Notification Customization
  5. Action Handlers
  6. Background Sync
  7. Offline Support
  8. Testing Service Workers
  9. Debugging Tools
  10. 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
Enter fullscreen mode Exit fullscreen mode

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);
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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 || '/');
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

Testing Service Workers

Chrome DevTools Testing

  1. Open DevToolsApplicationService Workers
  2. Check "Update on reload" - Forces SW update on page refresh
  3. Unregister - Remove service worker completely
  4. 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: '/' }
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Advanced targeting strategies
  2. Notification scheduling
  3. Rate limiting and throttling
  4. Production deployment (Docker, Kubernetes)
  5. SSL/HTTPS configuration
  6. Load balancing
  7. Horizontal scaling
  8. Database optimization

Top comments (0)