
Flutter: Debugging FCM Push Notifications
Push works on Android but silent on iOS? Learn to fix APNs certificates, handle background messages, configure Notification Channels, and debug FCM integration errors.

Push works on Android but silent on iOS? Learn to fix APNs certificates, handle background messages, configure Notification Channels, and debug FCM integration errors.
Yellow stripes appear when the keyboard pops up? Learn how to handle layout overflows using resizeToAvoidBottomInset, SingleChildScrollView, and tricks for chat apps.

Think Android is easier than iOS? Meet Gradle Hell. Learn to fix minSdkVersion conflicts, Multidex limit errors, Namespace issues in Gradle 8.0, and master dependency analysis with `./gradlew dependencies`.

App crashes only in Release mode? It's likely ProGuard/R8. Learn how to debug obfuscated stack traces, use `@Keep` annotations, and analyze `usage.txt`.

App crashed with TypeError? Learn why 'Null is not a subtype of String' happens and how to make your JSON parsing bulletproof with Zod/Freezed.

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.
The handling logic differs drastically by the app's state. You must understand this lifecycle to debug effectively.
Foreground (App is Open):
FirebaseMessaging.onMessage.listen and manually trigger a UI element (Dialog, Snackbar, or Local Notification) to alert the user.flutter_local_notifications to show a heads-up notification while the app is running.Background (App is Minimized):
onMessageOpenedApp is triggered.Terminated (App is Killed):
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:
.p8 file. Keep it safe, you can only download it once..p8 file.Runner.xcworkspace.+ Capability.IS_GCM_ENABLED is YES.IS_ANALYTICS_ENABLED is YES (optional but recommended).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.
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.
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.
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:
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)
}
}
}
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:
apns payload for iOS settings (sound, badge).android payload for priority and channel.data fields are where you put custom logic (routing, id). They are available in message.data on the client.onTokenRefresh and update your backend.sound parameter in APNs payload or Background Fetch capability disabled.@pragma('vm:entry-point').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.