Flutter: Debugging FCM Push Notifications
1. "Why Can't I Receive Push?"
The logs form the server say "Success", but the phone remains silent. This is one of the most frustrating experiences in mobile development. Especially on iOS. While Android is somewhat forgiving, Apple demands perfection in certificates, profiles, and capabilities. If you miss one checkbox, it fails silently.
This guide provides a comprehensive troubleshooting framework for Flutter developers facing FCM connection issues.
2. The Principle: App States matter
The handling logic differs drastically by the app's state. You must understand this lifecycle to debug effectively.
-
Foreground (App is Open):
- Behavior: By default, NO banner is shown. This is by design.
- Action: You must listen to
FirebaseMessaging.onMessage.listenand manually trigger a UI element (Dialog, Snackbar, or Local Notification) to alert the user. - Library: Use
flutter_local_notificationsto show a heads-up notification while the app is running.
-
Background (App is Minimized):
- Behavior: System tray notification appears.
- Action: Clicking it brings the app to the foreground.
onMessageOpenedAppis triggered.
-
Terminated (App is Killed):
- Behavior: The hardest state. The system must wake up the app's background service.
- Action: If configured incorrectly (Android Priority or iOS Background Fetch), the OS will ignore the message to save battery.
3. Problem 1: iOS APNs Certificates
If iOS fails, 99% of the time, it is the Certificates or Provisioning Profiles.
Don't use the old .p12 certificates which expire yearly and are painful to manage. Use APNs Key (.p8).
Detailed Checklist:
- Apple Developer Console:
- Go to "Keys". Create a new key.
- Check "Apple Push Notifications service (APNs)".
- Download the
.p8file. Keep it safe, you can only download it once.
- Firebase Console:
- Go to Project Settings -> Cloud Messaging -> Apple app configuration.
- Upload the
.p8file. - Enter the Key ID (from the .p8 filename) and your Team ID (from Apple Membership).
- Xcode Capabilities:
- Open
Runner.xcworkspace. - Go to Signing & Capabilities ->
+ Capability. - Add Push Notifications.
- Add Background Modes and check Remote notifications. (Crucial! Without this, background pushes fail).
- Open
- GoogleService-Info.plist:
- Ensure
IS_GCM_ENABLEDisYES. - Ensure
IS_ANALYTICS_ENABLEDisYES(optional but recommended).
- Ensure
4. Problem 2: Android Notification Channel
Since Android 8.0 (Oreo), notifications without a Channel ID are discarded by the OS.
// Create channel in main.dart
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'high_importance_channel', // id
'Important Notifications', // name
description: 'This channel is used for important notifications.', // description
importance: Importance.max,
);
// Register it with the plugin
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
Server-Side Requirement:
When sending the payload from your server (Node.js, Python, Go), you MUST include the same channel_id in the android notification object.
5. Problem 3: Android Battery Optimization
Samsung, Xiaomi, and Chinese OEMs love to kill background apps to "optimize battery". To survive "Doze Mode" or aggressive battery savers, you must send High Priority messages.
{
"message": {
"token": "RECIPIENT_DEVICE_TOKEN",
"android": {
"priority": "high", // 👈 This wakes up the phone processor
"notification": {
"channel_id": "high_importance_channel"
}
}
}
}
If you send "normal" priority (default), the OS might batch the notification and deliver it minutes or hours later when the user turns on the screen.
6. Deep Dive 1: Background Handler
Processing data (e.g., saving to SQLite, updating a badge count) while the app is killed requires onBackgroundMessage.
It MUST be a Top-level function and annotated with @pragma('vm:entry-point').
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// If you need Firebase services, you must initialize them again here
await Firebase.initializeApp();
print("Handling a background message: ${message.messageId}");
}
void main() {
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(App());
}
Why Top-Level?
When the app is killed, the main isolate is dead. Android spawns a new background isolate to handle this callback. It cannot access class context or state from your MyApp widget. If you put this inside a class, the isolate will fail to find the entry point, and the app will crash silently in the background.
7. Deep Dive 2: iOS Notification Service Extension
Marketing teams love "Rich Notifications" (images, videos).
On Android, notification: { image: 'url' } works out of the box.
On iOS, it does not. You see the text, but no image.
Solution: You MUST add a Notification Service Extension target in Xcode. The workflow is:
- APNs wakes up the device.
- Before showing the notification, iOS hands the payload to your Extension.
- Your Extension (written in Swift/Obj-C) downloads the image from the URL.
- It attaches the downloaded file to the notification content.
- iOS displays the notification with the image.
Code Example (Swift):
// NotificationService.swift
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent = bestAttemptContent {
// Extract Image URL from "fcm_options" or custom data
if let urlString = bestAttemptContent.userInfo["image"] as? String,
let fileUrl = URL(string: urlString) {
// Download Logic...
// ...
// bestAttemptContent.attachments = [attachment]
}
contentHandler(bestAttemptContent)
}
}
}
8. Deep Dive 3: Server-Side Implementation (Node.js)
Implementing the backend correctly is half the battle. Using the legacy HTTP API is deprecated. Use the Firebase Admin SDK (HTTP v1).
const admin = require('firebase-admin');
admin.initializeApp();
const registrationToken = 'YOUR_DEVICE_TOKEN';
const message = {
token: registrationToken,
notification: {
title: 'Hello',
body: 'World'
},
// Android Specific config
android: {
priority: 'high',
notification: {
channelId: 'high_importance_channel',
icon: 'ic_notification',
color: '#ff0000'
}
},
// iOS Specific config
apns: {
payload: {
aps: {
sound: 'default',
contentAvailable: true, // Crucial for Silent Push / Background Fetch
badge: 1
}
}
},
// Custom Data for logic routing
data: {
click_action: 'FLUTTER_NOTIFICATION_CLICK',
screen: 'chat_room',
room_id: '42'
}
};
admin.messaging().send(message)
.then((response) => {
console.log('Successfully sent message:', response);
})
.catch((error) => {
console.log('Error sending message:', error);
});
Key Points:
- Always include
apnspayload for iOS settings (sound, badge). - Always include
androidpayload for priority and channel. datafields are where you put custom logic (routing, id). They are available inmessage.dataon the client.
9. Deep Dive Glossary
- APNs (Apple Push Notification service): The only gateway to send notifications to iOS devices. FCM acts as a proxy for APNs on iOS. You cannot bypass APNs.
- FCM (Firebase Cloud Messaging): Google's cross-platform messaging solution. Direct on Android, Proxy on iOS.
- Device Token (FCM Token): A unique identifier for a specific app instance on a specific device. Rotates when app is re-installed or data cleared. You must listen to
onTokenRefreshand update your backend. - Topic Messaging: Sending a message to infinite users who subscribed to a topic (e.g., "news", "sports") instead of managing individual tokens. Good for broadcasts, bad for user-specific alerts.
- SoI (Silent on iOS): A common issue where pushes arrive silently. Usually due to missing
soundparameter in APNs payload orBackground Fetchcapability disabled. - Critical Alerts: A special entitlement from Apple that allows notifications to bypass Do Not Disturb / Mute. Required for medical, security, public safety apps.
- Local Notification: Notifications scheduled by the app itself on the device, without internet. Useful for reminders or alarms.
10. Summary
- Check Xcode Capabilities & .p8 Keys. This is the #1 cause of iOS failure.
- Match Android Channel IDs. Android 8+ will mute you otherwise.
- Set High Priority. Defeat the aggressive battery optimizers.
- Isolate Background Handler. Use
@pragma('vm:entry-point'). - Use Extensions for Images. iOS needs help downloading media.
Sending is easy. Delivering consistently is hard. Debug stage by stage: Server -> FCM -> APNs -> Device OS -> App. Use tools like the Firebase Console "Test Message" feature to isolate client-side issues from server-side issues.