← Articles

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

  1. Enable Push Notifications capability in Xcode (Signing & Capabilities → + → Push Notifications)
  2. Enable Background Modes → Remote notifications
  3. 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.

Push notifications in Flutter with FCM — ANN Tech