← Articles

Push notifications in Flutter: a complete guide

By Mark · 22 March 20261 view

Push notifications are one of the highest-impact re-engagement tools in mobile. Getting them right means handling all three app states: foreground, background, and terminated.

Setup

dependencies:
  firebase_messaging: ^15.0.0
  firebase_core: ^3.0.0
  flutter_local_notifications: ^17.0.0

Requesting permission

iOS requires explicit permission. Android 13+ also requires it:

Future<void> requestPermission() async {
  final settings = await FirebaseMessaging.instance.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );
  debugPrint('Permission: ${settings.authorizationStatus}');
}

Getting and refreshing the FCM token

Future<void> registerDevice() async {
  final token = await FirebaseMessaging.instance.getToken();
  if (token != null) {
    await userRepo.saveDeviceToken(userId: currentUser.id, token: token);
  }

  // Token rotates — always listen for refreshes
  FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
    userRepo.saveDeviceToken(userId: currentUser.id, token: newToken);
  });
}

Handling all three app states

// Must be a top-level function (not a class method)
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  // Process background message — never update UI here
}

void main() {
  // Register BEFORE runApp
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
  runApp(const MyApp());
}

class NotificationService {
  Future<void> init(GoRouter router) async {
    // 1. App in FOREGROUND — FCM doesn't show a notification banner
    FirebaseMessaging.onMessage.listen((message) {
      _showLocalNotification(message); // Show it manually
    });

    // 2. App in BACKGROUND — user tapped the notification
    FirebaseMessaging.onMessageOpenedApp.listen((message) {
      _navigateFromMessage(router, message);
    });

    // 3. App TERMINATED — launched by tapping notification
    final initial = await FirebaseMessaging.instance.getInitialMessage();
    if (initial != null) _navigateFromMessage(router, initial);
  }

  void _navigateFromMessage(GoRouter router, RemoteMessage message) {
    final path = message.data['deep_link'] as String?;
    if (path != null) router.go(path);
  }
}

Showing foreground notifications with flutter_local_notifications

Future<void> _showLocalNotification(RemoteMessage message) async {
  final n = message.notification;
  if (n == null) return;

  await _localNotifications.show(
    n.hashCode,
    n.title,
    n.body,
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'orders', 'Order Updates',
        importance: Importance.high,
        priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
    payload: jsonEncode(message.data),
  );
}

Android notification channels

Android 8+ requires notification channels. Users can silence individual channels in system settings:

await _localNotifications
    .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
    ?.createNotificationChannel(const AndroidNotificationChannel(
      'orders',
      'Order Updates',
      description: 'Status updates for your orders',
      importance: Importance.high,
    ));

Common pitfalls

Registering the background handler after runApp. FirebaseMessaging.onBackgroundMessage must be called before runApp(). If called after, background messages are silently dropped.

Not handling token refresh. FCM tokens rotate when the app is reinstalled, when the user clears app data, or after long inactivity. Always sync the new token to your server via onTokenRefresh.

Assuming getInitialMessage is always non-null. It's only non-null when the app was launched from a terminated state by tapping a notification. Always null-check it.

Sign in to like, dislike, or report.