Building a modern hybrid app often means living in two worlds at once: the open web and the native mobile experience. When I set out to add push notifications to Atmos Football, the goal was simple — stop the endless Tuesday-night WhatsApp chase and let players know automatically when they're in for Thursday's game.

What I didn't expect was how fragmented the documentation would be. Web push and native Android notifications behave very differently, even when both use the same Firebase Cloud Messaging (FCM) project. This post is the map I wish I'd had.

Why Push Notifications for a Football App?

In amateur 5-a-side, the biggest enemy isn't the opposition — it's forgetfulness and organiser burnout.

Without notifications, I was manually pinging 12–18 players every week via WhatsApp to confirm attendance. Many players would only open the app after the game had started (or not at all). The result: last-minute dropouts, unbalanced teams, and a lot of unnecessary stress. The organiser absorbs these costs invisibly. They do not appear in the app's data. They show up in the hours before each game as a personal tax on whoever cares most about the group continuing to function.

The vision was straightforward: when a match is confirmed or a player is added to the squad, they get a quiet, reliable system notification — "You're in for Thursday 7pm at the usual hall. Don't forget your boots." One notification that works whether they're on the PWA in Chrome or the installed Android app.

This single feature turned Atmos from a passive stats tracker into an active part of the group's weekly routine.

The Platform Landscape: Two Very Different Paths

The complexity comes from the fact that web and Android handle FCM differently.

Web (PWA): Uses the Web Push API + VAPID keys. FCM delivers the message, but the browser's service worker receives it and calls showNotification().

Android (Capacitor): Uses the @capacitor-firebase/messaging plugin. FCM talks directly to the Android OS — no service worker involved.

You're using one Firebase project and one Firestore database, but the registration, permission, and display paths are completely different. Most guides cover only one platform. Very few explain how to keep both working cleanly from the same codebase and token store. Each platform's documentation is complete for that platform. Neither tells you what breaks when you try to run both. The gap between them is where the actual work lives.

Web Push Implementation: The Service Worker Struggle

For the PWA side, the flow is strict:

  1. Generate VAPID keys in the Firebase Console (Project Settings → Cloud Messaging).
  2. Create a service worker file named firebase-messaging-sw.js at the root of your domain — this is critical, it must not be in a subfolder.
  3. Request notification permission — never on first load.
  4. Get the FCM token with the VAPID key and save it to Firestore with platform: 'web'.
  5. Handle background messages in the service worker.

Here's the core of the service worker:

// firebase-messaging-sw.js (at domain root)
importScripts('https://www.gstatic.com/firebasejs/10.x.x/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.x.x/firebase-messaging-compat.js');

firebase.initializeApp({ /* config */ });
const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) => {
  const notificationTitle = payload.notification?.title || "Atmos Football";
  const notificationOptions = {
    body: payload.notification?.body,
    icon: '/icon-192x192.png'
  };
  return self.registration.showNotification(notificationTitle, notificationOptions);
});

The main gotcha: service workers require a secure HTTPS context in production. Testing on localhost works fine in Chrome and Firefox (they allow service workers without HTTPS for local development), but once you move to a staging or preview domain it must be HTTPS. Build tools like Vite can make the service worker path painful if you're not careful with your output config — the file needs to land at the domain root, not inside a subfolder like /assets/.

Android (Capacitor) Implementation: Native Power

On the Android side, Capacitor + @capacitor-firebase/messaging makes registration feel cleaner once it's set up.

import { FirebaseMessaging } from '@capacitor-firebase/messaging';

const { token } = await FirebaseMessaging.getToken();
await saveTokenToFirestore(token, 'android');

Key differences from the web path:

  • Android 13+ requires the POST_NOTIFICATIONS runtime permission before any notification can appear.
  • Foreground messages land in a listener and you control the UI. Background messages are shown automatically by the OS.
  • Prefer data messages over notification messages for flexibility — you can decide whether to show a notification or silently update the UI if the user is already in the app.

Gotchas we hit:

  • Make sure google-services.json is correctly placed in android/app/. A missing or outdated file causes silent failures during token registration.
  • Custom URL schemes in Capacitor can sometimes confuse Firebase — double-check your capacitor.config.ts and verify androidScheme is set to 'https' (required for Firebase Auth cookies and Firestore).
  • Once a user denies the permission dialog on Android, you can't programmatically re-ask. You have to guide them to system settings.

Sending Notifications and Token Management

We trigger notifications via Firebase Cloud Functions — Firestore triggers fire when a match is confirmed or updated. The function fetches all valid tokens for the group from a dedicated fcmTokens collection, then sends a multicast message using the Firebase Admin SDK.

Token lifecycle is critical. Tokens expire, users clear browser data, or uninstall the app. We handle this by:

  • Storing token, platform, userId, and lastUpdated per token document.
  • Catching messaging/registration-token-not-registered errors and immediately deleting stale tokens.
  • Periodically cleaning up very old tokens via a scheduled function.

This keeps delivery rates high and Firebase costs under control.

The Permission Flow: Don't Be That App

This is where most notification implementations fail.

Never ask for permission immediately on load — the denial rate is brutal. In Atmos we use a soft prompt strategy:

  • Show an in-app toggle in "My Account" labelled "Enable match notifications" with a clear explanation.
  • Only after the user explicitly enables it do we show the actual browser or Android permission dialog.

On Android, if the user denies the system dialog twice, you're locked out permanently for that app install. You then have to direct them through system settings — a conversion killer. We ask at the moment the user has seen value (after recording their first game or joining a group), when they're most likely to say yes.

Lessons Learned

Push notifications were more plumbing than I expected, but they've made the biggest difference to the day-to-day experience of the group. The first time a player messaged me saying "nice, I got the reminder" felt like a win.

Most of the complexity here is not technical — it is the complexity of maintaining a system across two environments with different rules, different failure modes, and different user expectations. The technical problems have solutions. The harder problem is knowing which environment you are actually in when something breaks.

If you're building a similar React + Capacitor hybrid app:

  • Start with the web service worker and get that working first — it has more moving parts and more failure modes.
  • Use a consistent token schema in Firestore (token, platform, userId, lastUpdated).
  • Document every gotcha as you go — the second time you hit the same problem, you'll thank yourself.

Push notifications turned Atmos Football from a passive record-keeper into an active participant in our weekly games. It's a lot of work, but when that first "You're in for Thursday" lands on someone's lock screen, it all feels worth it.

Try the app — notifications are opt-in. For the full story, see How We Built Atmos Football.

— James