Push notifications in Flutter with FCM
By John · 4 May 2025
Push notifications in Flutter require Firebase Cloud Messaging (FCM) for delivery and native platform setup for permissions. Here is the complete setup including background message handling.
Setup
dependencies:
firebase_messaging: ^15.0.0
firebase_core: ^3.0.0
flutter_local_notifications: ^17.0.0 # For customizing notification appearance
iOS configuration
- Enable Push Notifications capability in Xcode (Signing & Capabilities → + → Push Notifications)
- Enable Background Modes → Remote notifications
- Upload APNs key in Firebase Console → Project Settings → Cloud Messaging → APNs Auth Key
<!-- ios/Runner/Info.plist -->
<key>FirebaseAppDelegateProxyEnabled</key>
<false/> <!-- Manage token registration yourself -->
Android configuration
Android 13+ requires explicit notification permission. FCM works without it but notifications are silently blocked:
dependencies:
permission_handler: ^11.3.0
if (Platform.isAndroid) {
final status = await Permission.notification.status;
if (!status.isGranted) {
await Permission.notification.request();
}
}
Initialization
Future<void> initPushNotifications() async {
final messaging = FirebaseMessaging.instance;
// Request permission (iOS/macOS/web)
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false, // True = deliver quietly without asking
);
if (settings.authorizationStatus != AuthorizationStatus.authorized) {
return; // User denied
}
// Get the FCM token (send to your backend for targeted notifications)
final token = await messaging.getToken();
await saveTokenToBackend(token!);
// Listen for token refresh
messaging.onTokenRefresh.listen(saveTokenToBackend);
// Set up message handlers
_setupMessageHandlers();
}
Future<void> saveTokenToBackend(String token) async {
await api.post('/users/push-token', body: {'token': token});
}
Handling messages
Messages arrive in three scenarios:
void _setupMessageHandlers() {
// 1. App in FOREGROUND: message received
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
// 2. App in BACKGROUND: user taps notification
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// 3. App TERMINATED: user taps notification to open app
_checkInitialMessage();
}
void _handleForegroundMessage(RemoteMessage message) {
// FCM doesn't show a notification when the app is in foreground
// Use flutter_local_notifications to show one manually
_showLocalNotification(message);
}
void _handleNotificationTap(RemoteMessage message) {
final link = message.data['deep_link'] as String?;
if (link != null) {
router.go(link);
}
}
Future<void> _checkInitialMessage() async {
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleNotificationTap(initial);
});
}
}
Background message handler
The background handler must be a top-level function (not a class method):
// Top-level function
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Handle background message (update local data, show notification)
print('Background message: ${message.messageId}');
}
// In main():
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}
Local notifications (foreground)
final _localNotifications = FlutterLocalNotificationsPlugin();
Future<void> initLocalNotifications() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings();
await _localNotifications.initialize(
const InitializationSettings(android: androidSettings, iOS: iosSettings),
onDidReceiveNotificationResponse: (response) {
// Handle tap on local notification
if (response.payload != null) {
router.go(response.payload!);
}
},
);
}
void _showLocalNotification(RemoteMessage message) {
final notification = message.notification;
if (notification == null) return;
_localNotifications.show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
'default_channel',
'Default',
importance: Importance.high,
priority: Priority.high,
icon: '@drawable/ic_notification',
),
iOS: const DarwinNotificationDetails(),
),
payload: message.data['deep_link'] as String?,
);
}
Sending notifications from backend (Node.js)
const admin = require('firebase-admin');
await admin.messaging().send({
token: userFcmToken,
notification: {
title: 'Order shipped!',
body: 'Your order #1234 is on its way.',
},
data: {
deep_link: '/orders/order-1234',
order_id: 'order-1234',
},
apns: {
payload: { aps: { sound: 'default', badge: 1 } },
},
android: {
priority: 'high',
notification: { sound: 'default' },
},
});
Common pitfalls
Background handler not a top-level function. FCM runs the background handler in a separate Dart isolate. If it's a method on a class, it throws. Must be a top-level or static function.
Sending to stale tokens. FCM tokens change when users reinstall the app or clear data. Listen to onTokenRefresh and update your backend. When FCM returns messaging/registration-token-not-registered, remove the token.
Not calling getInitialMessage(). If a user taps a notification while the app is terminated, onMessageOpenedApp doesn't fire. Always check getInitialMessage() on startup.
Sign in to like, dislike, or report.