Skip to content
ExponaBLOG
FLUTTER · DAY 061 / 100

Push notifications with FCM: what developers should know

Reliable push on iOS and Android.

Expona Admin7 min read

Introduction

Most teams ship their first push notification and think the job is done. A few weeks later, notifications stop arriving, or users see duplicates, or the app crashes in the background. Push notifications with FCM: what developers should know starts with a simple truth: Firebase Cloud Messaging (FCM) is straightforward to integrate, but its reliability depends entirely on how you handle permissions, state transitions, and message routing. This guide covers what production teams learn the hard way, so you can get it right from day one.

Request permission at the right moment

Permission timing is everything. On iOS, requesting notification permission too early—say, on app launch—trains users to tap "Don't Allow" reflexively. On Android 13+, the same applies. The best practice is to ask when the user is engaged and understands why they need notifications. A common flow is to show an in-app prompt first, explain the value, then trigger the OS permission dialog.

Here's how we handle it in Flutter:

dart
import 'package:firebase_messaging/firebase_messaging.dart';

class NotificationService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  Future<void> requestPermission() async {
    final settings = await _messaging.requestPermission(
      alert: true,
      announcement: false,
      badge: true,
      carPlay: false,
      criticalAlert: false,
      provisional: false,
      sound: true,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      print('User granted permission');
    } else if (settings.authorizationStatus == AuthorizationStatus.provisional) {
      print('User granted provisional permission');
    } else {
      print('User denied permission');
    }
  }
}

On Android, check the actual manifest permissions as well. The plugin handles runtime permissions, but ensure your AndroidManifest.xml declares POST_NOTIFICATIONS. On iOS 12 and below, permissions are implicit; iOS 13+ requires explicit user approval.

Time the request strategically. After the user completes their first key action (signs up, views their first order, joins a team) is ideal. If they decline, don't re-ask immediately. Store the refusal and try again after several days or after a contextual moment that demonstrates value.

Handle foreground, background and terminated states

FCM messages arrive in three distinct lifecycle states, and each requires different handling. This is where many teams struggle because the behavior differs subtly between iOS and Android.

Foreground state: The app is open and active. By default, FCM delivers the message to your app code, not the OS notification panel. You must decide whether to show a notification anyway using local notifications.

Background state: The app is suspended but not terminated. On both platforms, FCM shows the system notification. Your app can have a background message handler that runs in a restricted environment.

Terminated state: The app process is dead. The system shows the notification. Tapping it launches the app, and you extract the payload from the launch message.

Set up listeners for all three:

dart
class FCMSetup {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  void initializeHandlers() {
    // Foreground messages
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('Foreground message: ${message.messageId}');
      
      if (message.notification != null) {
        _showLocalNotification(message);
      }
    });

    // Background message handler (must be a top-level function)
    FirebaseMessaging.onBackgroundMessage(_backgroundMessageHandler);

    // Message opened from terminated state
    FirebaseMessaging.instance.getInitialMessage().then((message) {
      if (message != null) {
        _handleNotificationTap(message);
      }
    });

    // Message opened from background state
    FirebaseMessaging.onMessageOpenedApp.listen((message) {
      print('Message opened: ${message.messageId}');
      _handleNotificationTap(message);
    });
  }

  static Future<void> _backgroundMessageHandler(
    RemoteMessage message,
  ) async {
    print('Background message: ${message.messageId}');
    // Limited execution window. Avoid long-running tasks.
  }

  void _handleNotificationTap(RemoteMessage message) {
    final data = message.data;
    if (data.containsKey('deeplink')) {
      _navigateTo(data['deeplink']!);
    }
  }

  void _showLocalNotification(RemoteMessage message) {
    final notification = message.notification;
    // Use flutter_local_notifications to display in foreground
  }
}

The background message handler is tricky. It runs in a separate isolate with limited access to your app's state. You cannot update UI or access global singletons reliably. Use it only for essential tasks like data sync or analytics. On iOS, background execution is throttled by the OS; expect a few seconds at most.

Always test all three states. Use the Firebase Console to send test messages, and also use adb and Xcode debugging tools to simulate background and terminated scenarios locally.

Send data messages for app logic

FCM supports two message types: notification messages and data messages. Notification messages have a built-in title and body that the OS displays automatically. Data messages are pure payload—key-value pairs you parse yourself.

For most app logic, prefer data messages. They give you full control over timing and behavior across all three lifecycle states. Combine them with local notifications for UI consistency.

A typical scenario: an order update notification. Send a data message with the order ID and status:

dart
// Data message structure from your backend
{
  "data": {
    "type": "order_update",
    "orderId": "12345",
    "status": "shipped",
    "trackingUrl": "https://example.com/track/12345"
  }
}

Handle it in the foreground by checking your local cache first, then showing a notification only if the data is new:

dart
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  final data = message.data;
  
  if (data['type'] == 'order_update') {
    final orderId = data['orderId']!;
    final status = data['status']!;
    
    // Check if we already know about this update
    final cachedStatus = _orderCache[orderId];
    if (cachedStatus != status) {
      _orderCache[orderId] = status;
      _showOrderNotification(orderId, status);
    }
  }
});

In the background, you can update local data and let the system notification do the work. In the terminated state, the notification acts as the entry point, and your launch handler extracts the payload.

Avoid sending both notification and data together unless you have a clear reason. It's usually redundant and complicates handling across states.

A notification that doesn't take users somewhere useful is wasted. Deep linking ensures tapping a notification opens the right screen in your app, with the right context.

Include a deep link URL in your data payload:

dart
// Backend sends:
{
  "data": {
    "type": "order_update",
    "orderId": "12345",
    "deeplink": "app://orders/12345"
  }
}

On the app side, set up a deep link handler. Use the go_router package for clean routing:

dart
import 'package:go_router/go_router.dart';

final router = GoRouter(
  routes: [
    GoRoute(
      path: 'orders/:orderId',
      builder: (context, state) {
        final orderId = state.pathParameters['orderId']!;
        return OrderDetailScreen(orderId: orderId);
      },
    ),
  ],
);

// In your notification handler:
void _handleNotificationTap(RemoteMessage message) {
  final deeplink = message.data['deeplink'];
  if (deeplink != null) {
    router.go(deeplink);
  }
}

For terminated state launches, extract the message in your main function and route accordingly before building the app:

dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
  String? initialRoute;
  
  if (initialMessage != null) {
    final deeplink = initialMessage.data['deeplink'];
    if (deeplink != null) {
      initialRoute = deeplink;
    }
  }
  
  runApp(MyApp(initialRoute: initialRoute));
}

Test deep links thoroughly on both platforms. Use adb shell am start -W -a android.intent.action.VIEW -d "app://orders/12345" com.example.app on Android and xcrun on iOS to simulate notification taps without actually sending FCM messages.

Common mistakes

  • Blocking on the main thread in background handlers: Background message handlers have a strict timeout. Long database queries or network calls fail silently. Keep them light or queue work for later.
  • Forgetting to handle the terminated state: Many teams test only foreground and background. The terminated state is where users experience the most friction. Always call getInitialMessage() on app launch.
  • Asking for permissions on every app launch: Check authorization status before requesting again. Repeated prompts train users to opt out.
  • Mixing notification and data messages carelessly: If you send both, handle them separately. On some platforms, the system notification takes precedence, and your data handler never fires.
  • Not testing on real devices: Emulators and simulators don't reliably receive FCM messages. Always test on physical devices, especially for background and terminated states.
  • Ignoring platform differences: iOS requires Capability configuration and provisioning profiles. Android requires runtime permissions on 6.0+. Don't assume parity.

Key takeaways

  • Request notification permission at a moment of user engagement, not on app launch. Check authorization status before re-requesting.
  • Implement handlers for all three lifecycle states: foreground, background, and terminated. Each behaves differently on iOS and Android.
  • Use data messages for flexibility and control. Combine them with local notifications for consistent UI. Reserve notification messages for simple alerts.
  • Always include a deep link in your notification payload. Route to the correct screen immediately on notification tap.
  • Test on real devices, not emulators. Simulate all three states using platform-specific tools before shipping.
  • Keep background message handlers lightweight. They run in an isolate with a strict timeout and limited environment access.
  • Monitor delivery and engagement metrics. Track which notifications reach users and which drive action.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles