The Consolidated "Bulletproof" Push Listener
To fix the "hit or miss" behavior on mobile, we need to move away from relying on the Firebase SDK's internal listener (which is a black box that doesn't always play nice with mobile power management) and instead wrap everything in the native push event using event.waitUntil.

Replace your current messaging.onBackgroundMessage and self.addEventListener('push') blocks with this unified version:

JavaScript
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
self.addEventListener('push', (event) => {
  console.log('[SW] Push event received. Messaging Ready:', !!messaging);

  // event.waitUntil is the "Keep-Alive" signal for mobile OS
  event.waitUntil(
    (async () => {
      try {
        let payload;

        // 1. Try to parse the data directly from the push event (Fastest/Reliable)
        if (event.data) {
          try {
            payload = event.data.json();
            console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
          } catch (e) {
            console.warn('[SW] Could not parse JSON, using text fallback');
            payload = { notification: { body: event.data.text() } };
          }
        }

        // 2. If the payload is empty, check if Firebase can catch it 
        // (This happens if your server sends "Notification" instead of "Data" messages)
        if (!payload && messaging) {
          // This is a last-resort wait for the SDK
          payload = await new Promise((resolve) => {
            const timeout = setTimeout(() => resolve(null), 2000);
            messaging.onBackgroundMessage((bgPayload) => {
              clearTimeout(timeout);
              resolve(bgPayload);
            });
          });
        }

        // 3. Construct and show the notification
        if (payload) {
          const n = payload.notification || {};
          const d = payload.data || {};
          
          // Use the specific function you already defined
          await showRosterChirpNotification({
            title:   n.title   || d.title   || 'New Message',
            body:    n.body    || d.body    || '',
            url:     d.url     || d.link    || '/', // some SDKs use 'link'
            groupId: d.groupId || '',
          });
        } else {
          // Fallback if we woke up for a "ghost" push with no data
          await self.registration.showNotification('RosterChirp', {
            body: 'You have a new update.',
            tag: 'rosterchirp-fallback'
          });
        }
      } catch (error) {
        console.error('[SW] Critical Push Error:', error);
      }
    })()
  );
});
Why this fixes your issues:
Eliminates the Race Condition: By wrapping the logic in an async function inside event.waitUntil, you tell Android/iOS: "Do not kill this process until the notification is actually rendered on the screen."

Bypasses SDK "Sleep": Sometimes the Firebase SDK takes too long to initialize. This code tries to parse the event.data directly first. Direct parsing is almost instantaneous, which is critical when a phone is in a deep sleep state.

Handles "Ghost" Pushes: Sometimes FCM sends a "heartbeat" or an empty push to wake the device. This script ensures that even if data is missing, the worker stays alive long enough to handle it gracefully rather than crashing.

One Final Server-Side Check
Since you are using HAProxy, check your server logs for the HTTP status code when you send a message to a mobile device.

If you see 200 OK but no notification: The issue was definitely this Service Worker "sleep" issue.

If you see 401 or 403: HAProxy might be stripping the Authorization header from your backend's outbound request to Google.

If you see 400: Ensure your backend is sending priority: "high" in the FCM JSON.

Would you like me to provide a Python or Node.js snippet to test sending a "High Priority" message with the correct v1 API headers?