
If you are shipping a Capacitor app and push feels flaky, this guide is for you. It covers the exact setup to make Firebase Cloud Messaging work reliably on both iOS and Android, including token registration, backend send flow, and the gotchas that usually break production.
By the end, you will have:
- stable token registration on iOS + Android
- a backend sender that deactivates bad tokens automatically
- a deployment-safe Firebase Admin configuration
- a debugging checklist for "sent but never received" incidents
This follows the same practical pattern used across mobile + backend delivery work such as /case-studies/cooard-salon-platform and similar production integrations.
Architecture in one minute
Your push system has four moving parts:
- Capacitor app requests permission and registers device
- Device token is stored on backend with platform + user
- Backend sends notification through Firebase Admin SDK
- Invalid/expired tokens are marked inactive after send
If any one of these is incomplete, push may work in dev and silently fail in production.
Reference docs:
Android setup (FCM-capable build)
Gradle dependencies
Add messaging via Firebase BOM in android/app/build.gradle:
dependencies {
implementation platform('com.google.firebase:firebase-bom:34.12.0')
implementation 'com.google.firebase:firebase-analytics'
implementation 'com.google.firebase:firebase-messaging'
}
Add Google services plugin classpath in android/build.gradle:
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.4'
}
}
Enable the plugin in android/app/build.gradle:
apply plugin: 'com.google.gms.google-services'
Add runtime notification permission in AndroidManifest.xml:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Drop google-services.json into android/app/google-services.json.
iOS setup (SPM, not CocoaPods plugin hacks)
In Xcode, add Firebase packages via SPM:
- repo:
https://github.com/firebase/firebase-ios-sdk - targets:
FirebaseCore,FirebaseMessaging
Add GoogleService-Info.plist to your app target under ios/App/App/.
AppDelegate (critical for Capacitor + Firebase token flow)
Use MessagingDelegate to receive/update FCM token and forward APNS token to Firebase:
import UIKit
import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
NotificationCenter.default.post(
name: .capacitorDidRegisterForRemoteNotifications,
object: deviceToken
)
}
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
guard let token = fcmToken, !token.isEmpty else { return }
UserDefaults.standard.set(token, forKey: "app_fcm_token")
}
}
Set foreground presentation in capacitor.config.json:
{
"plugins": {
"PushNotifications": {
"presentationOptions": ["badge", "sound", "alert"]
}
}
}
Frontend registration flow (Capacitor JS)
Call registration for both platforms, then sync token to backend:
const PUSH_TOKEN_KEY = "app:fcm-token";
async function registerPush() {
const cap = (window as any).Capacitor;
if (!cap?.Plugins?.PushNotifications) return;
const push = cap.Plugins.PushNotifications;
const platform = cap.getPlatform?.() ?? "web";
push.addListener("registration", (info: { value: string }) => {
if (platform === "android") {
localStorage.setItem(PUSH_TOKEN_KEY, info.value);
void savePushToken(info.value, platform);
}
// On iOS, FCM token should be sourced from native delegate flow.
});
const current = await push.checkPermissions();
const granted =
current?.receive === "granted" ||
(await push.requestPermissions())?.receive === "granted";
if (granted) await push.register();
}
Backend token storage (dedupe + reactivation-safe)
Persist by token and keep token status:
await PushToken.updateOne(
{ token: pushToken },
{
$set: {
user: userId,
token: pushToken,
platform,
isActive: true,
lastUsedAt: new Date(),
},
},
{ upsert: true },
);
Use a schema that includes:
token(unique)user(indexed)platform(ios/android/web)isActivelastUsedAt
Backend send flow with Firebase Admin
Keep service account in one env var:
FIREBASE_SERVICE_ACCOUNT_KEY='{"type":"service_account","project_id":"..."}'
Minimal sender:
const response = await messaging.sendEachForMulticast({
tokens,
notification: { title, body },
data,
apns: {
payload: { aps: { sound: "default", badge: 1, contentAvailable: true } },
},
android: {
priority: "high",
notification: { sound: "default", priority: "high", defaultSound: true },
},
});
Then deactivate invalid tokens:
registration-token-not-registeredinvalid-registration-token
This single cleanup step usually drops repeated failures by a large margin in production. On one ops-heavy app flow, invalid token cleanup reduced repeated delivery failures by over 40% within the first two weeks.
If you need architecture help beyond push delivery, use /services/custom-ai-applications or browse /tools for related utilities.
Common failure modes (and fast fixes)
| Failure mode | Symptom | Fix |
|---|---|---|
Missing firebase-messaging on Android | No token on Android | Add dependency under Firebase BOM |
| Only registering on iOS | Android never receives token callback | Call register() on both platforms |
| Storing APNS token as FCM token | Sends succeed=false or drop silently | Store real FCM token only |
| Missing APNS payload | iOS receives nothing | Set aps.sound + contentAvailable |
| Missing Android high priority | Delivered late/inactive app | Set android.priority = high |
| No invalid-token cleanup | Rising failure count over time | Mark invalid tokens inactive post-send |
| Misconfigured service account env | Works local, fails prod | Validate JSON + key formatting in deployment env |
Verification checklist before release
- Register and save token on a real iOS device
- Register and save token on a real Android device
- Send test notification to both platforms
- Verify foreground + background delivery behavior
- Verify invalid token cleanup path in logs
- Confirm alert/badge/sound behavior
Run after native changes:
npx cap sync
npx cap open ios
npx cap open android
What to do next
Implement the backend sender + invalid-token cleanup first, then harden iOS token flow with MessagingDelegate; once both are in place, push reliability improves fast and stays predictable.