Push notifications in Flutter with FCM
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.