Push notifications are crucial for user engagement in Progressive Web Apps (PWAs), but implementing them reliably across different devices and browsers presents unique challenges. This article chronicles our journey building a production-ready notification system for ClarityBox, detailing the technical architecture, problems encountered, and debugging strategies we developed.
The Challenge: More Than Just “Hello World” Notifications
When we started implementing push notifications for ClarityBox, our mental health tracking PWA, we thought it would be straightforward. Send a notification, user receives it, done. We quickly discovered that real-world PWA notifications involve complex interactions between Service Workers, browser engines, operating systems, and push services.
Our requirements were specific:
- Daily reminders for mood tracking at user-specified times
- Cross-platform reliability (Android Chrome, iOS Safari, Desktop)
- Timezone-aware scheduling with server-side cron jobs
- Robust error handling and diagnostic capabilities
- Battery-optimized delivery that respects device power management
Architecture Overview: Hybrid Web Push + FCM Infrastructure
After evaluating different approaches, we settled on a hybrid architecture that combines Web Push standards with Firebase Cloud Messaging (FCM) infrastructure.
Why Not Pure Firebase?
Many tutorials recommend Firebase’s client SDK approach:
// ❌ Common Firebase approach - creates vendor lock-in
import { messaging } from './firebase-config'
messaging.onBackgroundMessage((payload) => {
// Heavy dependency on Firebase
})
Instead, we chose Web Push standards with FCM as the delivery infrastructure:
// ✅ Our approach - Standards-based with FCM infrastructure
self.addEventListener('push', function (event) {
const data = event.data.json()
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192x192.png',
tag: 'claritybox-reminder',
requireInteraction: true,
silent: false
})
})
System Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Cloud Cron │───▶│ Next.js API │───▶│ web-push lib │
│ (Scheduler) │ │ (/cron/send) │ │ (Standards) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User Device │◀───│ FCM Infrastructure │◀───│ Push Endpoint │
│ (Service Worker)│ │ (Google Servers) │ │ Detection │
└─────────────────┘ └─────────────────┘ └─────────────────┘
This architecture provides:
- Flexibility: Can switch from FCM to other push services
- Performance: Lightweight Service Worker
- Standards Compliance: Follows W3C Web Push specifications
- Infrastructure Benefits: Leverages Google’s robust FCM delivery network
Problem 1: The “Missing Notifications” Mystery
Our first major challenge appeared during testing: notifications worked perfectly when the app was recently used but failed after extended periods (24+ hours).
The Investigation
Initial logs showed successful server-side delivery:
✅ Notification sent to user 123456789 (Europe/Paris) with high priority
✅ Cron job completed: 1 sent, 0 failed across 1 timezones
Yet users reported no notifications on their devices. This led us down a debugging rabbit hole that revealed the complexity of mobile power management.
Root Cause: Android Doze Mode and Battery Optimization
The culprit was Android’s aggressive battery optimization. After extended inactivity:
- Apps enter Doze Mode, limiting network activity
- Service Workers get suspended by the system
- Push subscriptions become temporarily unreachable
- Battery optimization deprioritizes PWA notifications
Solution: Enhanced Service Worker Configuration
We enhanced our Service Worker with Android-specific optimizations:
// Enhanced notification options for Android reliability
const options = {
body: data.body,
icon: data.icon || '/icons/icon-192x192.png',
tag: 'claritybox-reminder', // Prevents duplicates
requireInteraction: true, // Forces persistent display
silent: false, // Ensures sound/vibration
data: { url: data.url || '/' },
}
Key improvements:
requireInteraction: true: Forces Android to display the notification even in power-saving modestagsystem: Prevents notification spam and manages duplicates- Explicit sound policy: Ensures notifications aren’t silently delivered
Problem 2: Service Worker Cache Persistence Issues
During development, we encountered situations where code changes weren’t reflected in production, leading to inconsistent behavior across devices.
The Debug Strategy: Service Worker Versioning
We implemented a comprehensive versioning and diagnostic system:
// sw.js
const SW_VERSION = '1.2.0'
const SW_CACHE_NAME = `claritybox-v${SW_VERSION}`
self.addEventListener('message', function(event) {
if (event.data && event.data.type === 'GET_VERSION') {
event.source.postMessage({
type: 'SW_VERSION_RESPONSE',
version: SW_VERSION,
cacheName: SW_CACHE_NAME
})
}
})
And a corresponding React component for real-time diagnostics:
// ServiceWorkerVersion.tsx
const getServiceWorkerVersion = async (): Promise<ServiceWorkerInfo> => {
const registration = await navigator.serviceWorker.ready
const sw = registration.active
return new Promise((resolve, reject) => {
const messageChannel = new MessageChannel()
messageChannel.port1.onmessage = (event) => {
if (event.data.type === 'SW_VERSION_RESPONSE') {
resolve({
version: event.data.version,
cacheName: event.data.cacheName,
isOnline: navigator.onLine,
lastUpdated: new Date()
})
}
}
sw.postMessage({ type: 'GET_VERSION' }, [messageChannel.port2])
})
}
This system provides:
- Version transparency: Always know which SW version is running
- Update verification: Confirm deployments reach user devices
- Cache debugging: Track cache states across sessions
- Network status: Monitor online/offline states
Problem 3: Frontend State Synchronization
Users reported that the notification settings UI showed “Enable notifications” even when notifications were already active, creating confusion and potential duplicate subscriptions.
The Synchronization Challenge
The issue stemmed from the asynchronous nature of PWA initialization:
- Component mounts → Shows default “disabled” state
- Service Worker loads → Checks real subscription status
- Firestore loads → Retrieves user preferences
- State conflicts → UI shows incorrect status
Solution: Unified Initialization Strategy
We implemented a comprehensive initialization flow:
async function initializeComponent() {
setIsInitializing(true)
try {
// 1. Check real browser subscription first
const registration = await navigator.serviceWorker.ready
const realSubscription = await registration.pushManager.getSubscription()
// 2. Load Firestore settings
const userData = await getExtendedUserData(user.uid)
const reminderSettings = userData?.reminderSettings
// 3. Intelligently synchronize states
if (realSubscription) {
setSubscription(realSubscription)
if (reminderSettings) {
setIsReminderEnabled(reminderSettings.enabled || false)
setReminderTime(reminderSettings.time || '18:00')
}
} else {
setSubscription(null)
setIsReminderEnabled(false)
}
} catch (error) {
// Handle initialization errors
} finally {
setIsInitializing(false)
}
}
Key improvements:
- Browser-first approach: Prioritize actual subscription state
- Graceful loading: Show loading indicators during initialization
- State reconciliation: Intelligently merge browser and database states
- Error boundaries: Handle partial failures gracefully
Debugging Strategies: Building Observability into PWAs
PWA debugging presents unique challenges due to Service Worker lifecycle management and cross-origin restrictions. We developed several strategies:
1. Comprehensive Logging Strategy
// Structured logging with version info
console.log(`[SW v${SW_VERSION}] Push Received.`)
console.log(`[SW v${SW_VERSION}] Push data: `, data)
2. Server-Side Diagnostics
Our cron job includes detailed subscription debugging:
console.log('🔧 Reconstructed subscription:', {
endpoint: subscription.endpoint,
keys: subscription.keys
})
console.log('🔧 Endpoint valid:', !!subscription.endpoint)
console.log('🔧 Keys valid:', !!(subscription.keys.p256dh && subscription.keys.auth))
3. Real-Time Status Dashboard
The ServiceWorkerVersion component provides live diagnostics:
- Current SW version and cache status
- Network connectivity state
- Last successful communication timestamp
- Force update capabilities
4. Progressive Enhancement Testing
We test across multiple scenarios:
- Fresh installation: Clean browser state
- Extended inactivity: 24+ hour gaps
- Network transitions: WiFi to mobile data
- Battery optimization: Various Android power settings
Technical Implementation Details
Push Subscription Management
// Robust subscription creation with error handling
async function subscribeToPush() {
try {
const permission = await Notification.requestPermission()
if (permission !== 'granted') throw new Error('Permission denied')
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
})
// Store subscription with metadata
await updateUserPushSubscription(user.uid, {
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth'))
},
userAgent: navigator.userAgent,
createdAt: new Date(),
isActive: true
})
} catch (error) {
// Comprehensive error handling
}
}
Timezone-Aware Scheduling
// Server-side cron with timezone handling
const now = new Date()
const utcTime = `${now.getUTCHours().toString().padStart(2, '0')}:${now.getUTCMinutes().toString().padStart(2, '0')}`
// Group users by timezone for efficient processing
const timezoneGroups = users.reduce((groups, user) => {
const timezone = user.reminderSettings?.timezone || 'UTC'
if (!groups[timezone]) groups[timezone] = []
groups[timezone].push(user)
return groups
}, {})
// Process each timezone independently
for (const [timezone, usersInTimezone] of Object.entries(timezoneGroups)) {
const localTime = new Date().toLocaleTimeString('en-GB', {
timeZone: timezone,
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
// Send notifications for matching times
const matchingUsers = usersInTimezone.filter(user =>
user.reminderSettings?.time === localTime
)
}
Lessons Learned and Best Practices
1. Embrace the Hybrid Approach
Pure Firebase implementations create vendor lock-in and increase bundle size. Our hybrid approach provides:
- Standards compliance with infrastructure benefits
- Migration flexibility if push providers change
- Performance optimization through lighter Service Workers
2. Plan for Power Management
Mobile operating systems aggressively manage battery usage:
- Use
requireInteraction: truefor critical notifications - Implement retry mechanisms for failed deliveries
- Educate users about battery optimization settings
- Consider notification timing for maximum visibility
3. Build Comprehensive Diagnostics
PWA debugging requires visibility into multiple layers:
- Service Worker version tracking
- Subscription state monitoring
- Network connectivity awareness
- Cross-device testing capabilities
4. Handle Graceful Degradation
Not all environments support all features:
- Check for Service Worker support
- Validate Push Manager availability
- Provide fallbacks for unsupported browsers
- Maintain functionality without notifications
Conclusion
Building reliable push notifications for PWAs requires understanding the complex interplay between web standards, mobile operating systems, and user expectations. Our journey taught us that technical implementation is only half the battle—the other half involves comprehensive debugging, monitoring, and graceful handling of edge cases.
The hybrid architecture we developed provides a solid foundation for any PWA requiring reliable push notifications. By combining Web Push standards with robust infrastructure, implementing comprehensive diagnostics, and planning for mobile power management, we achieved a notification system that works reliably across devices and usage patterns.
Key takeaways for developers building similar systems:
- Start with standards, leverage infrastructure
- Build debugging capabilities from day one
- Test extensively across devices and time periods
- Plan for power management and edge cases
- Monitor everything in production
The complete implementation serves thousands of users with reliable daily reminders, proving that with the right architecture and debugging strategies, PWA notifications can be as reliable as native apps.
If you want a step-by-step guide