Push notifications are the most powerful re-engagement tool a mobile app has. They are also one of the most error-prone features to ship. Firebase Cloud Messaging (FCM) makes them tractable across iOS and Android. This guide covers a real-world Flutter integration end-to-end.
Setting Up Firebase
Use the FlutterFire CLI — it generates the platform configs and Dart bindings for you:
dart pub global activate flutterfire_cli
flutterfire configure
flutter pub add firebase_core firebase_messaging flutter_local_notifications
iOS: APNs Configuration
- Enable Push Notifications and Background Modes (Remote notifications) in Xcode
- Create an APNs Auth Key in Apple Developer console (.p8 file)
- Upload the key to Firebase Console → Project Settings → Cloud Messaging
- Add
aps-environmententitlement
Android: Defaults and Channels
Android 8+ requires notification channels. Create them once on first launch:
const channel = AndroidNotificationChannel(
'high_importance_channel',
'Important Notifications',
description: 'Used for time-sensitive alerts.',
importance: Importance.high,
);
await FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
Requesting Permission
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true, badge: true, sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
final token = await messaging.getToken();
await uploadTokenToServer(token);
}
On Android 13+, also request POST_NOTIFICATIONS runtime permission.
Three Notification States
- Foreground: app is open. FCM does not show a notification — you do.
- Background: app is minimized. System tray shows the notification.
- Terminated: app is killed. System tray shows it; tap launches the app.
Handling Foreground Messages
FirebaseMessaging.onMessage.listen((RemoteMessage msg) async {
await flutterLocalNotificationsPlugin.show(
msg.hashCode,
msg.notification?.title,
msg.notification?.body,
NotificationDetails(
android: AndroidNotificationDetails(
'high_importance_channel', 'Important Notifications',
importance: Importance.high, priority: Priority.high,
),
iOS: const DarwinNotificationDetails(),
),
payload: jsonEncode(msg.data),
);
});
Handling Background Messages
Background handlers must be top-level functions:
@pragma('vm:entry-point')
Future<void> backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// Persist or process silently
}
void main() {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(backgroundHandler);
runApp(MyApp());
}
Notification Taps and Deep Links
// App opened from background
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
router.go(msg.data['route'] ?? '/');
});
// App launched from terminated state
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
router.go(initial.data['route'] ?? '/');
}
Targeting
- Token: send to a specific device
- Topic:
messaging.subscribeToTopic('news')— fanout to all subscribers - Condition: combinations like
'news' in topics && 'sports' in topics - User segments: Firebase Analytics audiences
Sending from Your Backend
// Node.js with firebase-admin
const message = {
token: deviceToken,
notification: { title: 'New order', body: 'Order #1234 just shipped' },
data: { route: '/orders/1234' },
android: { priority: 'high' },
apns: { payload: { aps: { sound: 'default' } } },
};
await admin.messaging().send(message);
Token Lifecycle
Tokens expire and refresh. Listen for changes and update the server:
FirebaseMessaging.instance.onTokenRefresh.listen(uploadTokenToServer);
Delete tokens on logout to avoid leaking notifications to a different user on the same device.
Rich Notifications
- iOS: Notification Service Extension to download and attach images
- Android:
BigPictureStyle, action buttons, inline replies - Use the
datapayload to drive routing and analytics
Testing
- Firebase Console → Cloud Messaging → Test message for one-off sends
- Real iOS devices only — APNs does not work on simulators
- Verify all three states: foreground, background, terminated
- Test on Android with Doze mode enabled
Common Pitfalls
- Forgetting the iOS APNs auth key upload
- Sending notification-only payloads when you needed data-only
- Not setting
priority: highon Android, causing delivery delays - Deep links that crash on cold start because services are not ready
Conclusion
Push done right is a re-engagement multiplier. FCM with Flutter handles the cross-platform plumbing; the engineering rigor is on you to handle all three app states, deep-link safely, and respect user permission. Build it once and every feature gets a high-leverage channel back to the user.