We hit a wall. Our notification system, built on Firebase and Web Push, worked perfectly on desktop browsers, but in our installed PWA, it was a ghost town. The first notification would arrive like clockwork, but any subsequent one would fail with an infuriating “subscription has expired” error. This is the story of how we diagnosed and fixed a classic, subtle, and frustrating PWA lifecycle problem.


SECTION 1: The Ghost Subscription Problem & The Service Worker Lifecycle

The symptoms were clear and maddening. A user would subscribe to notifications, receive the first scheduled reminder, and then… nothing. Our server-side logs on Firebase App Hosting revealed the truth: we would attempt to send the second notification, and the push service (e.g., Google’s FCM) would reject it with a 410 Gone status, stating the subscription was invalid or had expired.

To understand why, we must first understand the Service Worker lifecycle, which is at the heart of every PWA. A Service Worker is a JavaScript file that runs in the background, separate from the web page, enabling features like push notifications and offline caching. Its lifecycle is designed for safety and consistency, but this can be a double-edged sword in a PWA context.

The lifecycle consists of several states:

  1. Installing: The browser detects a new or updated sw.js file and begins to install it.
  2. Installed (Waiting): The new Service Worker has been successfully installed but is not yet active. It waits for all open clients (tabs, PWA windows) using the old Service Worker to be closed. This is a safety mechanism to prevent two different versions of the worker from running simultaneously and potentially corrupting caches or states.
  3. Activating: Once all old clients are closed, the new Service Worker activates and takes control.
  4. Active: The worker is now running and can handle events like fetch and push.

In a standard browser tab, a simple refresh is often enough to close the old client and activate the new worker. However, an installed PWA is different. Users don’t “refresh” it; they open and close it. The old Service Worker can remain active for a very long time, leading to a cascade of failures:

  1. Stale Service Worker: The PWA would continue running an old, cached version of the Service Worker, even after we deployed updates to our application. The browser saw the new sw.js but kept it in the “waiting” state, never activating it.
  2. Out-of-Sync Logic: Our client-side application (React components), running the latest code, had newer logic for handling subscriptions. The background Service Worker, however, did not. This created a dangerous state mismatch between the foreground and background processes.
  3. Subscription Expiration: Push subscriptions are not permanent. They are designed to be refreshed. The push service can invalidate them for security reasons, or if the user clears their browser data. Our stale Service Worker wasn’t equipped with the logic to handle this renewal, so the subscription token simply died after the first successful use, without the client ever knowing.

This culminated in the 410 Gone error. The push service was correctly telling us, “The endpoint you are trying to reach is permanently gone because the subscription token I gave you is no longer valid.”

SECTION 2: Solution, Part 1: Forcing an Aggressive Service Worker Update

The first step was to make our Service Worker take control immediately upon being updated. By default, a new Service Worker will download and then wait. We needed to tell it to be more assertive.

We added two key event listeners to our public/sw.js file to make its lifecycle more assertive.

// In public/sw.js

// Event: install
// Fired when the browser detects a new version of the service worker.
self.addEventListener('install', (event) => {
  console.log('[SW] A new version is installing...');
  
  // self.skipWaiting() forces the waiting service worker to become the active service worker.
  // This bypasses the "waiting" state, which is crucial for PWAs to ensure 
  // the latest background logic is run as soon as possible.
  self.skipWaiting();
});

// Event: activate
// Fired when the new service worker is activated. This happens after it has been installed
// and skipWaiting() has been called.
self.addEventListener('activate', (event) => {
  console.log('[SW] New version has been activated.');
  
  // event.waitUntil() ensures that the activation process is not terminated prematurely.
  // self.clients.claim() allows an active service worker to take control of all clients
  // (tabs, PWA windows) within its scope that are not currently controlled by another active worker.
  // This is the final step to ensure the new worker is in charge immediately.
  event.waitUntil(self.clients.claim());
});

// Other essential Service Worker logic remains, such as handling the push itself.
self.addEventListener('push', (event) => {
    // ... logic to show the notification ...
});

self.addEventListener('notificationclick', (event) => {
    // ... logic to handle notification clicks ...
});

These changes ensure that whenever a new Service Worker is deployed, it immediately replaces the old one and takes control of the application, running the latest background logic. The PWA no longer gets stuck with a stale worker.

SECTION 3: Solution, Part 2: Proactive Client-Side Subscription Syncing

Just updating the Service Worker isn’t enough. The client-side application needs to be smart enough to detect when its subscription has gone stale and get a new one. The server can’t do this alone; it only finds out the subscription is bad when it tries to use it. The client, however, can proactively check.

We implemented a robust checking mechanism in our NotificationScheduler.tsx React component. Every time the user visits the settings page, it silently performs these checks:

  1. Get Browser State: It asks the browser’s PushManager for its current, actual push subscription.
  2. Get Database State: It looks at the subscription data we’ve loaded from Firestore for that specific device.
  3. Compare and Re-sync: It compares the two. If there’s a mismatch, it triggers a re-subscription process.

Here are the key scenarios that trigger a re-sync:

  • The browser has no subscription, but Firestore thinks it does (e.g., user cleared site data).
  • The browser has a subscription, but its endpoint URL is different from the one in Firestore (push services can rotate endpoints).
  • The subscription is marked as inactive in our database.

Here’s a simplified version of the logic:

// Simplified logic in a React component (e.g., NotificationScheduler.tsx)

const resubscribeDevice = useCallback(async (reason: string) => {
    // This function handles the full re-subscription flow:
    // 1. Get a new subscription from the push service via pushManager.subscribe().
    // 2. Send this new subscription to our backend.
    // 3. Our backend saves the new, active subscription to Firestore.
}, [user, deviceId]);

useEffect(() => {
    // Only run if notifications are supported and permission is granted.
    if (!isSupported || permission !== 'granted' || userDisconnected) return;

    const checkSubscription = async () => {
        // 1. Get the current subscription from the browser's service worker.
        const registration = await navigator.serviceWorker.ready;
        const currentBrowserSub = await registration.pushManager.getSubscription();
        
        // 2. Get the subscription we have stored in our database for this device.
        const firestoreSub = pushSubscriptions ? pushSubscriptions[deviceId] : null;

        // 3. Compare and re-sync if necessary.
        if (!currentBrowserSub) {
            // The browser has no active subscription. If our database thinks it should,
            // we must re-subscribe to fix it.
            if (firestoreSub && firestoreSub.isActive) {
                 resubscribeDevice("Browser subscription is missing, but database shows active.");
            }
        } else if (!firestoreSub || !firestoreSub.isActive) {
            // The browser has a subscription, but our database doesn't know about it or has it marked inactive.
            // We need to update our database with the correct, active subscription.
            resubscribeDevice("Database subscription is missing or inactive.");
        } else if (currentBrowserSub.endpoint !== firestoreSub.endpoint) {
            // This is a subtle but important case. Push services can change an endpoint.
            // If it differs, we must update our database with the new one.
            resubscribeDevice("The subscription endpoint has changed.");
        }
    };

    checkSubscription();
}, [user, isSupported, permission, pushSubscriptions, deviceId, resubscribeDevice]);

SECTION 4: Solution, Part 3: Respecting User Intent with localStorage

Our auto-resync logic introduced a new bug: it was so aggressive that it would re-subscribe a user even if they had manually clicked “Disconnect this device”! The logic from Section 3 saw a “mismatch” (Firestore inactive, browser still potentially having a sub) and “fixed” it, against the user’s wishes.

The fix was simple but crucial: using localStorage to track the user’s explicit intent.

  1. On Disconnect: When a user clicks our “Disconnect” button, we set a flag in localStorage in addition to calling our backend function to mark the subscription as inactive in Firestore.

    // In the disconnect handler
    localStorage.setItem('claritybox_device_disconnected', 'true');
    await disableUserPushSubscription(user.uid, deviceId);
    
  2. On Connect: When they explicitly click “Connect this device”, we remove that flag. This signals their intent to receive notifications again.

    // In the connect handler
    localStorage.removeItem('claritybox_device_disconnected');
    // ... proceed with subscription logic ...
    
  3. In Auto-Resync Logic: Our automatic check from Section 3 now has one new guardrail: do not attempt to re-sync if the claritybox_device_disconnected flag is present in localStorage.

    // The check from the previous section, now with the user intent guard.
    const userDisconnected = localStorage.getItem('claritybox_device_disconnected') === 'true';
    if (!isSupported || permission !== 'granted' || userDisconnected) return;
    // ... rest of the sync logic ...
    

This ensures our app is smart enough to fix technical glitches (like an expired subscription) without overriding a deliberate user choice.


By combining an assertive Service Worker lifecycle with intelligent, proactive client-side checks—and a healthy respect for user intent—we finally squashed this elusive bug. Notifications are now reliable across all platforms, and the PWA behaves as it should. It’s a reminder that with PWAs, you’re not just building a website; you’re managing a complex, stateful application lifecycle.