← All posts

6 min read

Push Notifications in Flutter with FCM: Foreground, Background and Terminated States

A practical guide to push notifications in Flutter with Firebase Cloud Messaging: permissions, app states, tokens, Android channels, APNs setup and deep links.

  • Flutter
  • Push Notifications
  • Firebase
Cover illustration for Push Notifications in Flutter with FCM: Foreground, Background and Terminated States

Getting a push notification to show up on a phone takes about an hour. Getting push notifications to work properly takes a lot longer. “Properly” means they arrive in every app state, tapping one opens the right screen, they go to the right user on the right devices, and they don’t get your app’s permission prompt denied on first launch.

Most of the bugs I see in notification code aren’t in sending at all. They’re in the three app states, foreground, background and terminated, which each behave differently, and in the tokens nobody keeps up to date.

Here’s how I set up firebase_messaging in Flutter apps so it holds up in production.

Platform setup you can’t skip

iOS. Push on iOS goes through Apple’s APNs, and FCM needs to be allowed to talk to it. In your Apple Developer account, create an APNs authentication key (a .p8 file) and upload it in the Firebase console under Project settings → Cloud Messaging. In Xcode, add the Push Notifications capability and, under Background Modes, enable Remote notifications. Test on a real device. Simulator support for remote push exists but is limited, and it’s not where you want to debug delivery problems.

Android. Android 13 and above requires the POST_NOTIFICATIONS runtime permission. requestPermission() in firebase_messaging handles the prompt. On older versions, notifications are allowed by default.

Ask for permission at the right moment

The worst time to ask for notification permission is the first launch, before the user knows what your app does. On iOS, if they tap “Don’t Allow”, you can’t ask again from inside the app. They have to go to Settings.

Ask in context instead, right after the user does something that makes notifications obviously useful, like placing an order or booking a session:

Future<bool> enableOrderUpdates() async {
  final settings = await FirebaseMessaging.instance.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );
  return settings.authorizationStatus == AuthorizationStatus.authorized ||
      settings.authorizationStatus == AuthorizationStatus.provisional;
}

A short explanation screen before the system dialog (“Want us to tell you when your order ships?”) noticeably improves how many people say yes. And always build a working path for “no”. The app should never nag or break because notifications are off.

The three app states

This is the part that confuses everyone, so here it is in one place:

  • Foreground (app open and visible): notifications are not shown by the system on Android. You get them on FirebaseMessaging.onMessage and decide what to do.
  • Background (app alive but not visible): notification messages are shown by the system. Tapping one fires FirebaseMessaging.onMessageOpenedApp.
  • Terminated (app killed): the system shows the notification. Tapping it launches the app, and you read the message once with getInitialMessage().

Data-only messages that arrive while the app is in the background or terminated go to a background handler. It has to be a top-level function and annotated so the compiler doesn’t strip it in release builds:

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Runs in its own isolate: initialize Firebase before using it.
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  // Keep this short: update a badge count, store the payload, etc.
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
  runApp(const App());
}

The background handler runs in a separate isolate. It can’t touch your app’s state, providers or navigator. Treat it like a tiny standalone program.

A notification that opens the home screen is a missed opportunity. I put a route in the data payload and handle both the background and terminated cases with the same function:

Future<void> setupNotificationTaps(GoRouter router) async {
  final initial = await FirebaseMessaging.instance.getInitialMessage();
  if (initial != null) _openFromMessage(router, initial);

  FirebaseMessaging.onMessageOpenedApp.listen(
    (message) => _openFromMessage(router, message),
  );
}

void _openFromMessage(GoRouter router, RemoteMessage message) {
  final route = message.data['route'];
  if (route is String && route.startsWith('/')) {
    router.go(route);
  }
}

Only accept routes you expect. A payload is input, and input gets validated. If the target screen needs a logged-in user, let your router’s redirect logic handle it. I cover that in deep linking with go_router.

Showing notifications in the foreground

On iOS, you can let the system show notifications while the app is open:

await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
  alert: true,
  badge: true,
  sound: true,
);

On Android, foreground messages arrive silently on onMessage. To show them as real notifications, use flutter_local_notifications. In your onMessage listener, take the RemoteNotification title and body and pass them to the plugin’s show method with Android notification details that point at your channel. The plugin’s API has changed across major versions, so follow the example for the version in your pubspec.yaml.

Often an in-app banner or a badge update is a better foreground experience than a system notification anyway. If the user is already looking at the order screen, a notification about that order is noise.

Android notification channels

Since Android 8, every notification belongs to a channel, and users can mute channels individually. Create channels with meaningful names (“Order updates”, “Promotions”) instead of dumping everything into one. Users who can mute promotions are less likely to turn off notifications from your app entirely.

const orderChannel = AndroidNotificationChannel(
  'order_updates',
  'Order updates',
  description: 'Status changes for your orders',
  importance: Importance.high,
);

await FlutterLocalNotificationsPlugin()
    .resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>()
    ?.createNotificationChannel(orderChannel);

Then set a default channel in AndroidManifest.xml so FCM messages without an explicit channel land in a sensible place:

<meta-data
    android:name="com.google.firebase.messaging.default_notification_channel_id"
    android:value="order_updates" />

Your backend can also set channel_id per message in the Android section of the FCM payload.

Tokens: the part everyone forgets

Every installation gets an FCM registration token. Your backend sends to tokens, not to users. That means you need to:

  1. Get the token after login and send it to your backend.
  2. Listen for refreshes, because tokens change after reinstalls, restores and sometimes for no visible reason.
  3. Store tokens per user, per device. One user with a phone and a tablet has two tokens.
  4. Remove the token on logout, so the next person to use the device doesn’t get the previous user’s notifications.
Future<void> registerDevice(ApiClient api) async {
  final token = await FirebaseMessaging.instance.getToken();
  if (token != null) await api.registerPushToken(token);

  FirebaseMessaging.instance.onTokenRefresh.listen(api.registerPushToken);
}

On the server side, clean up tokens when FCM reports them as unregistered. Otherwise your token table fills up with dead devices. If you’re building that backend yourself, my post on building a .NET API for a Flutter app covers the surrounding structure.

Notification vs data payloads

FCM messages can carry a notification block, a data block, or both:

  • Notification messages are shown by the OS when the app is in the background or terminated. They’re reliable and need no app code to display.
  • Data-only messages are delivered to your code to handle. They’re good for silent syncs, but iOS treats background data messages as low priority and may throttle or delay them.

For anything the user needs to see, I send a notification block plus a small data block with the route and an ID. I don’t rely on data-only messages for user-visible alerts.

Testing

  • Use the Firebase console’s notification composer to send a test message to a specific token. Log the token in debug builds so you can copy it.
  • Test the full matrix: foreground, background and terminated, on both platforms, with both tap and no tap.
  • Test with notifications denied, and with a channel muted.
  • Test logout followed by login as a different user on the same device. It’s the classic token bug.

Takeaways

  • Ask for permission in context, after the user sees the value, and handle “no” gracefully.
  • Handle all three app states: onMessage, onMessageOpenedApp and getInitialMessage, plus a top-level background handler marked @pragma('vm:entry-point').
  • Put a validated route in the data payload so taps open the right screen.
  • Use meaningful Android channels so users can mute categories, not your whole app.
  • Store tokens per user and device, refresh them, and remove them on logout.

If you’re adding push notifications to a Flutter app and want them to work in every state on both platforms, get in touch.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).