Cross-Platform Notifications: FCM and APNs Together
A code-centric guide to wiring Firebase Cloud Messaging (FCM) and Apple Push Notification service (APNs) into a single Flutter app, from token handling to foreground and background notification delivery.
Published on • August 18, 2026
AI Assistant

Introduction
Every mobile product eventually needs push notifications, and every mobile team eventually hits the same wall: Android and iOS do not share a push transport. Android speaks to Firebase Cloud Messaging (FCM), while iOS routes through the Apple Push Notification service (APNs). The good news is that FCM is an APNs relay, not a competitor. On iOS, FCM accepts messages and forwards them to APNs; on Android it uses its own transport. That means one sending API, one message shape, and one client SDK can cover both platforms. In this post we wire FCM and APNs into a single Flutter app: Firebase project setup, per-platform config files, token acquisition, permission requests, foreground presentation via local notifications, and a background handler that runs even when your Dart isolate is cold.
Prerequisites
- A Flutter SDK (3.x) installation with Android and iOS toolchains
- A Firebase project (free Spark plan is enough for testing)
- Xcode with an Apple developer account capable of push notification entitlements
- A device or emulator running Android 4.4+ with Google Play services
How FCM and APNs Fit Together
FCM is a cross-platform messaging solution that reliably delivers payloads of up to 4096 bytes to client apps. Architecturally it has two main components: a trusted sending environment (your server or Cloud Functions) and the client app that receives messages through each platform’s transport. On Android, FCM uses Google Play services. On iOS, FCM requests a device token from APNs on your behalf, so you never touch APNs directly in Flutter. The important consequence: your backend sends one HTTP request to FCM, and FCM handles the platform split.
1. Setting Up the Firebase Project
Create the project at console.firebase.google.com, then add both an Android and an iOS app. Each gets its own config file. For Android, download google-services.json and place it in android/app/, then apply the Google services Gradle plugin in android/settings.gradle.kts:
// android/settings.gradle.kts
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
and in android/app/build.gradle.kts:
plugins {
id("com.android.application")
id("kotlin-android")
id("com.google.gms.google-services")
}
For iOS, download GoogleService-Info.plist and drop it into the ios/Runner/ directory in Xcode, making sure it is included in the Runner target. The two files seed the native Firebase SDKs with the same project identity.
2. Adding the Dependencies
Add the core Firebase plugin, FCM, and flutter_local_notifications for foreground presentation:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.8.0
firebase_messaging: ^15.1.3
flutter_local_notifications: ^18.0.1
firebase_messaging gives you the FirebaseMessaging instance, token streams, and message listeners. flutter_local_notifications is required because on both platforms FCM does not guarantee a visible notification when the app is in the foreground; you present it yourself.
3. Android Configuration
Android 13 and later gate notifications behind the POST_NOTIFICATIONS runtime permission, so declare it alongside the network permission in android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application android:label="cross_platform_notifications" android:name="${applicationName}" android:icon="@mipmap/ic_launcher">
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel" />
</application>
</manifest>
The default_notification_channel_id meta-data points FCM at a channel you will create in Dart. Without a channel, notification messages sent while the app is backgrounded are silently dropped on Android 8+.
4. iOS Configuration
iOS needs three things before it can receive a single push. First, enable the Push Notifications capability and the Background Modes (Background fetch and Remote notifications) in the Runner target under Xcode. Second, upload an APNs authentication key. In the Firebase console navigate to Settings > Cloud Messaging, click Upload under APNs authentication key, and provide the .p8 file, its key ID, and your Apple team ID. Third, initialize Firebase and forward notification delegates in ios/Runner/AppDelegate.swift:
import Firebase
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
FirebaseApp.configure()
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Keep FCM’s method swizzling enabled. The Flutter plugin relies on it for token handling and message delivery; disabling it breaks FCM token acquisition on Apple devices.
5. Requesting Notification Permission
On iOS permission must be requested explicitly. On Android 13+, requestPermission also triggers the system permission dialog. Call it once, early, and branch on the returned status:
Future<void> requestPermissions() async {
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional) {
debugPrint('Notification permission granted');
} else {
debugPrint('Notification permission denied');
}
}
Pass provisional: true if you want a quieter permission prompt that delivers notifications quietly until the user opts in.
6. Getting the FCM Registration Token
Tokens are the address your server uses to reach a specific device. Retrieve one with getToken() and keep it in sync with the onTokenRefresh stream, which fires on app startup and whenever the token rotates:
Future<String?> getToken() async {
final token = await FirebaseMessaging.instance.getToken();
debugPrint('FCM token: $token');
return token;
}
void watchToken() {
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
debugPrint('Token refreshed: $newToken');
});
}
On iOS, verify the APNs token is available before making FCM API calls by awaiting FirebaseMessaging.instance.getAPNSToken(). The APNs token is not guaranteed to exist at startup.
7. Foreground Messages and Local Notifications
When the app is in the foreground, FCM delivers messages to the onMessage stream instead of showing a system notification. This is where flutter_local_notifications takes over. Initialize it with platform settings, create a high-importance channel on Android, and present the incoming message:
const channel = AndroidNotificationChannel(
'high_importance_channel',
'High Importance Notifications',
description: 'Shown for alerts that need immediate attention',
importance: Importance.high,
);
final FlutterLocalNotificationsPlugin localNotifications =
FlutterLocalNotificationsPlugin();
Future<void> initLocalNotifications() async {
const settings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await localNotifications.initialize(settings);
await localNotifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
void listenForegroundMessages() {
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final notification = message.notification;
if (notification == null) return;
localNotifications.show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
channel.id,
channel.name,
channelDescription: channel.description,
),
iOS: DarwinNotificationDetails(),
),
);
});
}
8. Background and Terminated States
Notification messages are rendered by the OS when the app is backgrounded or terminated, so no Dart code runs for them. Data-only messages are different: they require a top-level handler that executes even when the isolate is cold. Register it before runApp with the vm:entry-point pragma so the compiler keeps it reachable:
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
debugPrint('Background message: ${message.messageId}');
debugPrint('Data: ${message.data}');
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
runApp(const App());
}
To react when the user taps a notification, also handle both cold and warm starts: getInitialMessage() returns the message that launched a terminated app, and onMessageOpenedApp fires when the app is opened from the background. Route on a type field in message.data to navigate to the right screen.
Putting It All Together
A production-ready service wires the pieces in order: initialize Firebase, request permission, set up local notifications, register the background handler, subscribe to the foreground stream, and push the token to your server. Keep the entire flow in a single NotificationService with idempotent init() so it can be called once from main(). Remember the platform split: the manifest meta-data and channel creation cover Android, the AppDelegate and APNs key cover iOS, and the Dart layer stays platform-agnostic.
Testing via the Firebase Console
You can validate the whole pipeline without writing a backend. Run the app on a device, accept the permission prompt, and log the token. In the Firebase console go to DevOps & Engagement > Messaging, create a Firebase Notification campaign, enter the message text, click Send test message, and paste the registration token. With the app backgrounded the OS renders the notification; with it foregrounded your onMessage listener presents it locally. The Messaging Reports dashboard shows sent and opened counts on both platforms.
Conclusion & Next Steps
FCM and APNs are not a zero-sum choice. FCM fronts both transports, so a single integration gives you reliable delivery on Android and a managed relay on iOS. You now have token acquisition, permission handling, foreground presentation, and a cold-start background handler wired into one Flutter app. Next, move sending off the console: the Admin SDK or FCM v1 API for server-side sends, topic subscriptions for groups of devices, and the FCM Data API with BigQuery export to measure delivery end to end.